A second studio refuses .runtime/ instead of repainting the first's rail

studio-impl found this and the founder found the symptom by opening it. Two
studios sharing a STUDIO_DIR share .runtime/, and composing is destructive: the
second to boot repainted the first's rail while the first kept serving the routing
table it built IN MEMORY at its own boot, and never reads that directory again.

The result is an app that is PRESENT, LOOKS INSTALLED, AND CANNOT WORK — a
PROVIDERS tab that loaded 200 while every verb behind it 404'd, because the process
showing the row had never heard of the provider. No check caught it and none of the
obvious ones could: the row is real, the page is real, the 200 is real. Same family
as the systemd unit hijack — a second instance quietly overwriting a first's state,
invisible because the running process keeps answering.

The second boot now refuses, by the rule already applied to ports and units:
REFUSE RATHER THAN ADOPT. A stale stamp — one whose port nothing is serving — is
taken over and SAID OUT LOUD, because a silent takeover is the same silence this
guard exists to remove.

LIVENESS IS THE STAMPED PORT, NOT THE PID. A pid can be reused by anything, so a
dead studio whose number came round again would refuse a legitimate boot forever.
The question that matters is whether something is still SERVING from this
.runtime, so that is the one asked — and it deliberately does not ask WHO answers,
because a stranger on that port is still a reason to refuse and asking the studio
would mean trusting a reply from the process we are unsure about.

REPRODUCED BEFORE FIXING, and the control is the point: with the stamp removed, a
studio composing ["files"] on one port silently changed a live studio's rail from
["workspace"] to ["floor","files"] while it went on serving. That is the defect,
measured.

SCOPE I DID NOT PROVE, stated because the commit would otherwise imply it: I
reproduced the REPAINT, not the 404. My fixture used an app whose verbs the
reference runtime answers itself, so it returned 200 and masked the routing gap;
the 404 needs a provider-backed app. The repaint is the cause and is what this
removes.

Also the first behavioural test tools/studio.mjs has ever had — it is a script with
no exports, so its boot has been hand-verified until now.

302 tests.
This commit is contained in:
mvschwarz committed 2026-08-06 01:31:06 -07:00
1 parent 02d7c9e470
commit bd9debce8f
2 files changed
+182

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
// Two studios must not share one .runtime/ silently.
//
// node --test 'test/*.test.mjs'
//
// THE DEFECT, found by studio-impl on a live box and by the founder OPENING IT:
// composing is destructive, and two studios sharing a STUDIO_DIR share .runtime/.
// The second to boot repainted the first's rail while the first kept serving the
// routing table it built IN MEMORY at ITS boot. The result is an app that is
// present, looks installed, and cannot work — a tab that loads 200 over verbs
// that 404, because the process showing the row never heard of the provider
// behind it. No check saw it. The row is real, the page is real, the 200 is real.
//
// NOTE ON SCOPE, because these tests prove the CAUSE and not the whole symptom:
// they assert the repaint is prevented. Reproducing the 404 needs a
// PROVIDER-BACKED app, since the reference runtime answers the files verbs itself
// and would mask it. The repaint is what the fix removes; the 404 follows from it.
//
// This is also the first behavioural test `tools/studio.mjs` has ever had — it is
// a script with no exports, so everything about its boot has been hand-verified.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import net from "node:net";
import path from "node:path";
import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const STUDIO_MJS = path.join(REPO, "tools", "studio.mjs");
const APPS = path.join(path.dirname(REPO), "openrig-studio-apps");
let nextPort = 9640;
const hasApps = fs.existsSync(path.join(APPS, "apps", "workspace", "app.json"));
function makeStudio(apps, port) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "studio-own-"));
fs.writeFileSync(path.join(dir, "studio.json"),
JSON.stringify({ port, appsRoot: APPS, apps }, null, 2));
return dir;
}
const listening = (port) => new Promise((resolve) => {
const s = net.connect(port, "127.0.0.1");
const done = (v) => { s.destroy(); resolve(v); };
s.once("connect", () => done(true));
s.once("error", () => done(false));
setTimeout(() => done(false), 500);
});
async function boot(dir, port) {
const proc = spawn(process.execPath, [STUDIO_MJS, "--port", String(port)],
{ cwd: dir, env: { ...process.env, OPENRIG_STUDIO_DIR: dir }, stdio: ["ignore", "pipe", "pipe"] });
for (let i = 0; i < 120; i++) {
if (await listening(port)) break;
await new Promise((r) => setTimeout(r, 100));
}
return { proc, stop: () => new Promise((r) => { proc.once("exit", r); proc.kill(); }) };
}
const railIds = (dir) => {
const p = path.join(dir, ".runtime", "surfaces", "surfaces.json");
return JSON.parse(fs.readFileSync(p, "utf8")).surfaces.map((s) => s.id);
};
test("a booting studio stamps .runtime/ with its own port", { skip: !hasApps && "apps repo not present" },
async (t) => {
const port = nextPort++;
const dir = makeStudio(["workspace"], port);
const a = await boot(dir, port);
t.after(() => a.stop());
const owner = JSON.parse(fs.readFileSync(path.join(dir, ".runtime", "owner.json"), "utf8"));
assert.equal(owner.port, port, "the stamp does not name this studio's port");
assert.equal(owner.studioRoot, dir);
assert.ok(owner.pid > 0 && owner.startedAt, "the stamp is missing pid or startedAt");
});
test("a SECOND studio on the same .runtime/ REFUSES instead of repainting",
{ skip: !hasApps && "apps repo not present" }, async (t) => {
const portA = nextPort++, portB = nextPort++;
const dir = makeStudio(["workspace"], portA);
const a = await boot(dir, portA);
t.after(() => a.stop());
const before = railIds(dir);
assert.ok(before.includes("workspace"), `first studio did not compose workspace: ${before}`);
// Same directory, different port, different app list — the exact shape.
fs.writeFileSync(path.join(dir, "studio.json"),
JSON.stringify({ port: portB, appsRoot: APPS, apps: ["files"] }, null, 2));
const second = spawnSync(process.execPath, [STUDIO_MJS, "--port", String(portB)],
{ cwd: dir, env: { ...process.env, OPENRIG_STUDIO_DIR: dir }, encoding: "utf8", timeout: 30000 });
assert.equal(second.status, 7, `expected refusal (exit 7), got ${second.status}: ${second.stderr}`);
assert.match(second.stderr, new RegExp(`port ${portA}`),
"the refusal does not name which studio owns the directory");
assert.deepEqual(railIds(dir), before,
"the first studio's rail was repainted anyway — its tabs now show apps it cannot route");
assert.ok(await listening(portA), "the first studio stopped serving");
});
test("a STALE stamp is taken over, and the takeover is announced",
{ skip: !hasApps && "apps repo not present" }, async (t) => {
const dir = makeStudio(["workspace"], nextPort++);
// A stamp naming a port nothing is serving: the owner is gone.
fs.mkdirSync(path.join(dir, ".runtime"), { recursive: true });
fs.writeFileSync(path.join(dir, ".runtime", "owner.json"),
JSON.stringify({ pid: 999999, port: 9999, studioRoot: dir, startedAt: "2020-01-01T00:00:00Z" }));
const port = nextPort++;
fs.writeFileSync(path.join(dir, "studio.json"),
JSON.stringify({ port, appsRoot: APPS, apps: ["workspace"] }, null, 2));
const b = await boot(dir, port);
t.after(() => b.stop());
const owner = JSON.parse(fs.readFileSync(path.join(dir, ".runtime", "owner.json"), "utf8"));
assert.equal(owner.port, port, "a stale stamp was not taken over — a dead studio blocks a live one forever");
});
test("positive control: this suite is capable of failing", () => {
assert.throws(() => assert.equal(1, 2));
});
+63
View File
@@ -38,12 +38,75 @@ const port = Number(arg("--port", String(config.port ?? 8890)));
const appsRoot = (config.appsRoot ?? path.join(STUDIO, "apps")).replace(/^~/, os.homedir());
const RUNTIME = path.join(path.dirname(new URL(import.meta.url).pathname), "..", "app", "serve-studio.mjs");
// ---- who owns .runtime/ ----------------------------------------------------
//
// TWO STUDIOS SHARING ONE STUDIO_DIR SHARE .runtime/, AND COMPOSING IS
// DESTRUCTIVE. The second to boot silently repainted the first's rail — and the
// first cannot notice, because it built its routing table IN MEMORY at ITS boot
// and never reads the directory again.
//
// The result is an app that is PRESENT, LOOKS INSTALLED, AND CANNOT WORK: the
// founder opened a PROVIDERS tab on a live studio that loaded 200 while every
// verb behind it 404'd, because the process serving it had never heard of the
// provider whose row it was now showing. Found by opening it. None of the
// obvious checks could have caught it — the row is real, the page is real, and
// the 200 is real.
//
// So the SECOND boot refuses rather than winning quietly. Same rule as the port
// guard and the systemd unit: REFUSE RATHER THAN ADOPT.
//
// LIVENESS IS THE STAMPED PORT, NOT THE STAMPED PID. A pid can be reused by
// anything, so a dead studio whose number came round again would refuse a
// legitimate boot forever. What matters is whether something is still SERVING
// from this .runtime, so that is what gets asked.
const RUNTIME_DIR = path.join(STUDIO, ".runtime");
const OWNER_FILE = path.join(RUNTIME_DIR, "owner.json");
// Deliberately does not ask WHO answers: a stranger on that port is still a
// reason to refuse, and asking the studio would mean trusting a reply from the
// process we are unsure about.
const portIsServing = (p) => new Promise((resolve) => {
const s = net.connect(Number(p), "127.0.0.1");
const done = (v) => { s.destroy(); resolve(v); };
s.once("connect", () => done(true));
s.once("error", () => done(false));
setTimeout(() => done(false), 700);
});
let priorOwner = null;
try { priorOwner = JSON.parse(fs.readFileSync(OWNER_FILE, "utf8")); } catch {}
if (priorOwner && Number(priorOwner.port) !== port) {
if (await portIsServing(priorOwner.port)) {
console.error("studio: .runtime/ in " + STUDIO + " is in use by the studio on port " + priorOwner.port);
console.error(" (pid " + (priorOwner.pid ?? "?") + ", started " + (priorOwner.startedAt ?? "?") + ")");
console.error(" Composing would REPAINT that studio's rail while it keeps serving the one it");
console.error(" built at ITS boot — its tabs would load and their verbs would 404, and it");
console.error(" cannot detect the change. Refusing rather than corrupting a running studio.");
console.error(" Give this studio its own OPENRIG_STUDIO_DIR, or stop port " + priorOwner.port + " first.");
process.exit(7);
}
// Stale stamp: the owner is gone. Take over and SAY SO — a silent takeover
// here is the same silence this guard exists to remove, with nobody harmed
// this time.
console.log(" runtime : taking over .runtime/ from a studio on port " + priorOwner.port + " that is no longer serving");
}
// ---- compose ---------------------------------------------------------------
const rail = composeRail({
appsRoot, enabled: config.apps ?? [], doors: config.doors ?? [],
runtimeDir: path.join(STUDIO, ".runtime"), studioRoot: STUDIO,
log: (m) => console.log(` app : ${m}`),
});
// Claim it only once the compose actually happened.
try {
fs.mkdirSync(RUNTIME_DIR, { recursive: true });
fs.writeFileSync(OWNER_FILE, JSON.stringify(
{ pid: process.pid, port, studioRoot: STUDIO, startedAt: new Date().toISOString() }, null, 2));
} catch (e) {
// Not fatal — a studio that cannot stamp still runs. But an unstamped
// .runtime/ is one the NEXT boot will silently take, so it is said out loud.
console.error(" warn : could not stamp .runtime/ ownership (" + e.message + ") — a second boot will not be warned");
}
for (const m of rail.missing) console.error(` MISSING : ${m}`);
for (const w of rail.warnings ?? []) console.error(` warn : ${w}`);