diff --git a/package.json b/package.json index 4863bb3521..daae7f5d81 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "xbox:test:oskiewar:rejoin": "node xbox/live/tests/netplay-rejoin.mjs", "xbox:watch:reel": "node xbox/live/marketing/reel-watch.mjs", "oskiewar:cull": "node xbox/live/marketing/reel.mjs --insights && node xbox/live/marketing/trim.mjs", + "xbox:test:oskiewar:release": "node --test xbox/tools/oskiewar-release.test.mjs", "xbox:burn:oskiewar-social": "node xbox/live/render-social-preview.mjs", "xbox:check:oskiewar-social": "node xbox/live/render-social-preview.mjs --check", "oskiewar:mac:build": "sh xbox/tools/build-macos-app.sh", diff --git a/xbox/tools/oskiewar-release.mjs b/xbox/tools/oskiewar-release.mjs index 658de458db..e20c74e0b1 100644 --- a/xbox/tools/oskiewar-release.mjs +++ b/xbox/tools/oskiewar-release.mjs @@ -4,6 +4,8 @@ import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { connect } from "node:net"; +import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -47,6 +49,57 @@ function run(command, args, options = {}) { } function git(...args) { return run("git", args, { capture: true }).trim(); } + +// Where the console lives, read from the same file `live.mjs` reads so the +// release and the transport can never disagree about which box they mean. +// A missing config is not a missing console: it is a machine that was never +// set up to talk to one, and it answers the same way — not here, not broken. +const devicePortalEnv = () => { + const path = process.env.XBOX_DEVICE_PORTAL_ENV || resolve(homedir(), + "aesthetic-computer/aesthetic-computer-vault/xbox/device-portal.env"); + const config = {}; + if (existsSync(path)) for (const raw of readFileSync(path, "utf8").split(/\r?\n/)) { + const match = raw.trim().match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match || raw.trim().startsWith("#")) continue; + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1); + config[match[1]] = value; + } + const merged = { ...config, ...process.env }; + return { host: merged.XBOX_DEVICE_PORTAL_HOST, + port: Number(merged.XBOX_DEVICE_PORTAL_PORT || 11443) }; +}; + +// Is the console awake? A plain TCP connect, with a short fuse. +// +// This exists because a switched-off Xbox is not a failed deployment, and the +// release used to record it as one: `live.mjs` curls the Device Portal, curl +// spends seventy-five seconds discovering there is nothing at the other end, +// and the channel lands as "failed · node exited 1" — which reads, in a +// receipt somebody checks later, exactly like a console that rejected the +// build. The two need to be distinguishable, because one of them is a +// problem and the other is a console in a cupboard. +// +// Two seconds is long enough for a device on the same LAN and short enough +// that nobody waits on it. Being unable to answer quickly IS the answer. +export function probeDevicePortal({ host, port }, timeout = 2000) { + if (!host) return Promise.resolve({ reachable: false, reason: + "no Device Portal configured on this machine" }); + return new Promise((settle) => { + const socket = connect({ host, port }); + const done = (reachable, reason) => { + socket.destroy(); + settle({ reachable, reason, host, port }); + }; + socket.setTimeout(timeout); + socket.once("connect", () => done(true, "")); + socket.once("timeout", () => done(false, + `${host}:${port} did not answer within ${timeout}ms`)); + socket.once("error", (error) => done(false, + `${host}:${port} ${error.code || error.message}`)); + }); +} function readReceipt() { if (!existsSync(receiptPath)) return null; try { return JSON.parse(readFileSync(receiptPath, "utf8")); } catch { return null; } @@ -119,18 +172,39 @@ async function reconcile(receipt, { dryRun = false } = {}) { if (receipt.channels.xbox.status !== "current") { if (dryRun) console.log("would deploy Xbox live source"); - else try { - run("node", ["xbox/tools/live.mjs", "deploy", "xbox/live/oskiewar.js"]); - mark(receipt, "xbox", "current", "Device Portal accepted source and launch"); - } catch (error) { mark(receipt, "xbox", "failed", error.message); } + else { + // Ask whether the console is there before spending a minute and a + // quarter finding out the hard way, and before writing "failed" over a + // channel that has nothing wrong with it. + const probe = await probeDevicePortal(devicePortalEnv()); + if (!probe.reachable) { + console.log(`→ xbox offline (${probe.reason}); skipping that channel`); + mark(receipt, "xbox", "offline", probe.reason); + } else try { + run("node", ["xbox/tools/live.mjs", "deploy", "xbox/live/oskiewar.js"]); + mark(receipt, "xbox", "current", "Device Portal accepted source and launch"); + } catch (error) { mark(receipt, "xbox", "failed", error.message); } + } } return receipt; } +// `parity` is still every channel current — an offline console has not got the +// build, and saying otherwise would make the receipt lie. What changes is that +// the run can now say the difference out loud: `blocked` is channels that +// actually went wrong, and it being empty is what "nothing is broken here" +// looks like when the Xbox is simply asleep. function print(receipt, current = null) { - console.log(JSON.stringify({ current, receipt, parity: receipt - ? channels.every((name) => receipt.channels[name]?.status === "current") - : false }, null, 2)); + const status = (name) => receipt?.channels?.[name]?.status; + const blocked = receipt ? channels.filter((name) => status(name) === "failed") : []; + const offline = receipt ? channels.filter((name) => status(name) === "offline") : []; + console.log(JSON.stringify({ current, receipt, + parity: receipt && channels.every((name) => status(name) === "current"), + blocked, offline }, null, 2)); + if (offline.length && !blocked.length) + console.log(`\nNothing failed. ${offline.join(", ")} ` + + `${offline.length === 1 ? "is" : "are"} offline — ` + + "turn it on and `npm run oskiewar:reconcile` will catch it up."); } async function main() { @@ -155,6 +229,14 @@ async function main() { current.severity, previous); receipt.desired.development = true; save(receipt); + // Asking for the Xbox explicitly and finding it asleep IS a failure of + // what you asked for — unlike the unified deploy, there is no other + // channel here to succeed. It still says which of the two happened. + const probe = await probeDevicePortal(devicePortalEnv()); + if (!probe.reachable) { + mark(receipt, "xbox", "offline", probe.reason); + throw new Error(`Xbox is offline: ${probe.reason}`); + } try { run("node", ["xbox/tools/live.mjs", "deploy", "xbox/live/oskiewar.js"]); mark(receipt, "xbox", "current", "explicit uncommitted Xbox development release"); diff --git a/xbox/tools/oskiewar-release.test.mjs b/xbox/tools/oskiewar-release.test.mjs new file mode 100644 index 0000000000..c5e2f55abb --- /dev/null +++ b/xbox/tools/oskiewar-release.test.mjs @@ -0,0 +1,81 @@ +// The release's failover, which is the one part of it that has to be right +// when nobody is watching: a console that is switched off must not land in a +// receipt looking like a console that rejected the build. + +import assert from "node:assert/strict"; +import { createServer } from "node:net"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { probeDevicePortal } from "./oskiewar-release.mjs"; + +const source = await readFile(new URL("./oskiewar-release.mjs", import.meta.url), "utf8"); + +test("a listening Device Portal answers the probe", async () => { + const server = createServer(); + await new Promise((ready) => server.listen(0, "127.0.0.1", ready)); + try { + const probe = await probeDevicePortal( + { host: "127.0.0.1", port: server.address().port }); + assert.equal(probe.reachable, true); + assert.equal(probe.reason, ""); + } finally { + await new Promise((closed) => server.close(closed)); + } +}); + +test("a console that is switched off is unreachable, not broken", async () => { + // Nothing is listening on this port, so the stack refuses immediately — + // which is the switched-off case, and it must come back as a plain + // unreachable with a reason a person can read. + const server = createServer(); + await new Promise((ready) => server.listen(0, "127.0.0.1", ready)); + const port = server.address().port; + await new Promise((closed) => server.close(closed)); + + const probe = await probeDevicePortal({ host: "127.0.0.1", port }); + assert.equal(probe.reachable, false); + assert.match(probe.reason, new RegExp(`127\\.0\\.0\\.1:${port}`)); +}); + +test("the probe gives up quickly rather than hanging the release", async () => { + // The whole point. curl spent 75 seconds discovering there was nothing at + // 192.168.1.101, and the deploy wore all of it before writing "failed". + // A short fuse is not impatience — being unable to answer quickly IS the + // answer for a device that is supposed to be on the same LAN. + const started = Date.now(); + const probe = await probeDevicePortal({ host: "192.0.2.1", port: 11443 }, 300); + assert.equal(probe.reachable, false); + assert.ok(Date.now() - started < 3000, + `the probe took ${Date.now() - started}ms and should have given up near 300`); +}); + +test("a machine with no Device Portal configured is unreachable, not an error", async () => { + const probe = await probeDevicePortal({ host: undefined, port: 11443 }); + assert.equal(probe.reachable, false); + assert.match(probe.reason, /no Device Portal configured/); +}); + +test("offline is its own channel status, distinct from failed", () => { + // The receipt has to keep the two apart, because one of them is a problem + // and the other is a console in a cupboard. + assert.match(source, /mark\(receipt, "xbox", "offline", probe\.reason\)/); + assert.match(source, /mark\(receipt, "xbox", "failed", error\.message\)/); + // The probe runs BEFORE the deploy, or the failover saves nothing. + const reconcile = source.match(/async function reconcile[\s\S]*?\n}\n/)[0]; + const probedAt = reconcile.indexOf("probeDevicePortal"); + const deployedAt = reconcile.indexOf('"xbox/tools/live.mjs"'); + assert.ok(probedAt > 0 && probedAt < deployedAt, + "the console is asked whether it is there before it is pushed to"); + // Parity stays honest: an offline console has not got the build. + assert.match(source, /parity: receipt && channels\.every\(\(name\) => status\(name\) === "current"\)/); + // And `blocked` is what actually went wrong, which is what makes an + // offline-only run readable as a success. + assert.match(source, /blocked = receipt \? channels\.filter\(\(name\) => status\(name\) === "failed"\)/); +}); + +test("asking for the Xbox explicitly still fails when it is asleep", () => { + // `deploy-xbox-dev` has no other channel to succeed, so an offline console + // is a failure of what was asked for — it just says which kind. + assert.match(source, /throw new Error\(`Xbox is offline: \$\{probe\.reason\}`\)/); +});