From 033fe9ad666239b34e6291e49ab579964a0797eb Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 13 Sep 2026 08:23:58 -0400 Subject: [PATCH] oskiewar release: a console in a cupboard is not a failed deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Xbox channel recorded "failed · node exited 1" whenever the devkit was switched off, which in a receipt somebody reads later is indistinguishable from a console that rejected the build. One of those is a problem and the other is a console in a cupboard, and the release could not tell you which. It also took seventy-five seconds to not tell you: `live.mjs` curls the Device Portal and curl wears its whole connect timeout discovering there is nothing at the other end, every deploy, after the web channel has already gone out. So the release asks first. A plain TCP connect with a two-second fuse, to the host read from the same env file `live.mjs` reads, so the release and the transport can never disagree about which box they mean. Unreachable is its own channel status — `offline`, with the reason — and the deploy is skipped rather than attempted and mourned. A machine with no Device Portal configured at all answers the same way, because it is the same answer: not here, not broken. `parity` is unchanged and still means every channel current, because an offline console genuinely has not got the build and a receipt that said otherwise would be lying. What is new is that the run can say the difference out loud: `blocked` is the channels that actually went wrong, and an empty `blocked` alongside a non-empty `offline` prints "Nothing failed." `deploy-xbox-dev` still throws, because asking for the Xbox explicitly and finding it asleep is a failure of what you asked for — there is no other channel there to succeed. It just says which of the two happened now. A deploy against the sleeping devkit went from 75s to 2.7s. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 1 + xbox/tools/oskiewar-release.mjs | 96 ++++++++++++++++++++++++++-- xbox/tools/oskiewar-release.test.mjs | 81 +++++++++++++++++++++++ 3 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 xbox/tools/oskiewar-release.test.mjs 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\}`\)/); +}); -- 2.51.2