test: run release v2 QA from shared standalone build
This commit is contained in:
1 parent
b40468f37c
commit
a149dbdfab
9 files changed
+266
-91
No files matched your search
@@ -144,9 +144,9 @@ const reportsRoot = join(qaRoot, "reports");
|
||||
const campaignCandidateSha = resolveCheckoutCandidateSha(repoRoot);
|
||||
const runtimeExecutionContract = createRuntimeExecutionContract();
|
||||
export const hostRuntimeLimitation =
|
||||
`The host's fs.inotify.max_user_instances=128 is too low for concurrent native Watchpack watchers. ` +
|
||||
`Spawned QA apps use the ${runtimeExecutionContract.bundler} bundler with ${runtimeExecutionContract.watcher.watchpackPollingIntervalMs}ms Watchpack polling; ` +
|
||||
"filesystem changes can take up to one polling interval to reach HMR.";
|
||||
`The host's fs.inotify.max_user_instances=128 is too low for concurrent development watchers. ` +
|
||||
`QA apps use ${runtimeExecutionContract.runtimeMode} from a single candidate-pinned build produced by the ${runtimeExecutionContract.bundler} bundler. ` +
|
||||
"Source changes require a new committed candidate build before browser evidence is valid.";
|
||||
const expectedProfileFlags: Record<string, string[]> = {
|
||||
"main-writable": ["DEMO_MODE=true", "DEMO_READ_ONLY=false", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"],
|
||||
"main-readonly": ["DEMO_MODE=true", "DEMO_READ_ONLY=true", "UNSAFE_ENABLE_MOCK_INTEGRATION=true"],
|
||||
|
||||
@@ -21,13 +21,13 @@ const candidateSha = resolveCheckoutCandidateSha(repoRoot);
|
||||
const blockerFingerprint = "release-v2-auth-locale-redirect-loop";
|
||||
|
||||
describe("release-v2 QA report runtime limitation", () => {
|
||||
it("describes the bundler and polling interval from the runtime contract", () => {
|
||||
it("describes the shared standalone runtime contract", () => {
|
||||
const runtimeExecutionContract = createRuntimeExecutionContract();
|
||||
|
||||
expect(hostRuntimeLimitation).toContain(`the ${runtimeExecutionContract.bundler} bundler`);
|
||||
expect(hostRuntimeLimitation).toContain(
|
||||
`${runtimeExecutionContract.watcher.watchpackPollingIntervalMs}ms Watchpack polling`,
|
||||
);
|
||||
expect(hostRuntimeLimitation).toContain(runtimeExecutionContract.runtimeMode);
|
||||
expect(hostRuntimeLimitation).toContain("single candidate-pinned build");
|
||||
expect(hostRuntimeLimitation).not.toContain("Watchpack polling");
|
||||
expect(hostRuntimeLimitation).not.toContain("Turbopack");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { closeSync, constants, existsSync, openSync } from "node:fs";
|
||||
import { chmod, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
||||
import { chmod, cp, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -8,6 +8,7 @@ import { spawn, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
acquireBuildLease,
|
||||
acquireSlotLease,
|
||||
assertSafeContainedPath,
|
||||
assertSafeRunRoot,
|
||||
@@ -19,8 +20,9 @@ import {
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
||||
const defaultRunRoot = path.join(tmpdir(), "homarr-release-v2-qa");
|
||||
const markerName = ".homarr-release-v2-qa-root.json";
|
||||
const watchpackPollingIntervalMs = 1_000;
|
||||
const runtimeMode = "production-standalone" as const;
|
||||
const runtimeBundler = "webpack" as const;
|
||||
const candidateBuildHeapLimitMb = 16_384;
|
||||
const profiles = ["main-writable", "main-readonly", "onboarding-fresh", "degraded"] as const;
|
||||
|
||||
type Profile = (typeof profiles)[number];
|
||||
@@ -32,50 +34,65 @@ export const assertRuntimeCommandPlatform = (command: RuntimeCommand, platform:
|
||||
};
|
||||
|
||||
interface RuntimeExecutionContract {
|
||||
runtimeMode: typeof runtimeMode;
|
||||
bundler: typeof runtimeBundler;
|
||||
watcher: { watchpackPollingIntervalMs: number };
|
||||
watcher: null;
|
||||
}
|
||||
|
||||
export const createRuntimeExecutionContract = (): RuntimeExecutionContract => ({
|
||||
runtimeMode,
|
||||
bundler: runtimeBundler,
|
||||
watcher: { watchpackPollingIntervalMs },
|
||||
watcher: null,
|
||||
});
|
||||
|
||||
export const validateRuntimeExecutionContract = (value: {
|
||||
runtimeMode?: unknown;
|
||||
bundler?: unknown;
|
||||
watcher?: { watchpackPollingIntervalMs?: unknown };
|
||||
watcher?: unknown;
|
||||
}): string[] => {
|
||||
const contractErrors: string[] = [];
|
||||
if (value.runtimeMode !== runtimeMode) contractErrors.push("runtime mode must be production-standalone");
|
||||
if (value.bundler !== runtimeBundler) contractErrors.push("bundler must be webpack");
|
||||
if (value.watcher?.watchpackPollingIntervalMs !== watchpackPollingIntervalMs) {
|
||||
contractErrors.push("Watchpack polling interval must be 1000ms");
|
||||
if (value.watcher !== null && value.watcher !== undefined) {
|
||||
contractErrors.push("standalone runtimes must not use a filesystem watcher");
|
||||
}
|
||||
return contractErrors;
|
||||
};
|
||||
|
||||
export const createAppLaunchCommand = (
|
||||
appPort: number,
|
||||
options: { platform?: NodeJS.Platform; prlimitAvailable?: boolean } = {},
|
||||
serverPath: string,
|
||||
options: { platform?: NodeJS.Platform; prlimitAvailable?: boolean; nodeExecutable?: string } = {},
|
||||
) => {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const prlimitAvailable = options.prlimitAvailable ?? existsSync("/usr/bin/prlimit");
|
||||
const pnpmArguments = [
|
||||
"--filter",
|
||||
"@homarr/nextjs",
|
||||
"dev",
|
||||
"--webpack",
|
||||
"--hostname",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
String(appPort),
|
||||
];
|
||||
const nodeExecutable = options.nodeExecutable ?? process.execPath;
|
||||
if (platform === "linux" && prlimitAvailable) {
|
||||
return {
|
||||
command: "/usr/bin/prlimit",
|
||||
arguments: ["--nofile=65536:1048576", "--", "pnpm", ...pnpmArguments],
|
||||
arguments: ["--nofile=65536:1048576", "--", nodeExecutable, serverPath],
|
||||
};
|
||||
}
|
||||
return { command: "pnpm", arguments: pnpmArguments };
|
||||
return { command: nodeExecutable, arguments: [serverPath] };
|
||||
};
|
||||
|
||||
export const createCandidateBuildPaths = (candidateSha: string) => {
|
||||
if (!/^[0-9a-f]{40}$/u.test(candidateSha)) {
|
||||
throw new Error("Candidate build requires a 40-character lowercase commit SHA");
|
||||
}
|
||||
const appRoot = path.join(repoRoot, "apps/nextjs");
|
||||
const distDirName = `.next-qa/release-v2-${candidateSha}`;
|
||||
const buildDir = path.join(appRoot, distDirName);
|
||||
const standaloneRoot = path.join(buildDir, "standalone");
|
||||
const standaloneAppDir = path.join(standaloneRoot, "apps/nextjs");
|
||||
return {
|
||||
appRoot,
|
||||
distDirName,
|
||||
buildDir,
|
||||
standaloneRoot,
|
||||
standaloneAppDir,
|
||||
serverPath: path.join(standaloneAppDir, "server.js"),
|
||||
markerPath: path.join(buildDir, "qa-build-manifest.json"),
|
||||
};
|
||||
};
|
||||
|
||||
interface Options {
|
||||
@@ -107,8 +124,10 @@ interface RuntimeManifest {
|
||||
fixtureUrl: string;
|
||||
ports: { app: number; fixture: number; redis: number };
|
||||
flags: { demoMode: boolean; demoReadOnly: boolean; unsafeMockIntegration: boolean };
|
||||
runtimeMode: typeof runtimeMode;
|
||||
bundler: typeof runtimeBundler;
|
||||
watcher: { watchpackPollingIntervalMs: number };
|
||||
watcher: null;
|
||||
build: { candidateSha: string; serverPath: string };
|
||||
processes: {
|
||||
app: { pid: number; logPath: string };
|
||||
fixture: { pid: number; logPath: string };
|
||||
@@ -325,14 +344,20 @@ interface SpawnedProcess {
|
||||
exited: Promise<number | null>;
|
||||
}
|
||||
|
||||
const spawnLogged = (command: string, args: string[], logPath: string, env: NodeJS.ProcessEnv): SpawnedProcess => {
|
||||
const spawnLogged = (
|
||||
command: string,
|
||||
args: string[],
|
||||
logPath: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: { cwd?: string } = {},
|
||||
): SpawnedProcess => {
|
||||
const output = openSync(
|
||||
logPath,
|
||||
constants.O_CREAT | constants.O_APPEND | constants.O_WRONLY | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
const child = spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
detached: true,
|
||||
env,
|
||||
stdio: ["ignore", output, output],
|
||||
@@ -352,6 +377,75 @@ const runForeground = (command: string, args: string[], env: NodeJS.ProcessEnv)
|
||||
throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status ?? "unknown"}`);
|
||||
};
|
||||
|
||||
const candidateBuildIsReady = async (candidateSha: string) => {
|
||||
const buildPaths = createCandidateBuildPaths(candidateSha);
|
||||
try {
|
||||
const marker = JSON.parse(await readFile(buildPaths.markerPath, "utf8")) as {
|
||||
schemaVersion?: number;
|
||||
candidateSha?: string;
|
||||
runtimeMode?: string;
|
||||
bundler?: string;
|
||||
};
|
||||
const serverStats = await stat(buildPaths.serverPath);
|
||||
const staticStats = await stat(path.join(buildPaths.standaloneAppDir, ".next/static"));
|
||||
const publicStats = await stat(path.join(buildPaths.standaloneAppDir, "public"));
|
||||
return (
|
||||
marker.schemaVersion === 1 &&
|
||||
marker.candidateSha === candidateSha &&
|
||||
marker.runtimeMode === runtimeMode &&
|
||||
marker.bundler === runtimeBundler &&
|
||||
serverStats.isFile() &&
|
||||
staticStats.isDirectory() &&
|
||||
publicStats.isDirectory()
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureCandidateBuild = async (runRoot: string, candidateSha: string) => {
|
||||
const buildPaths = createCandidateBuildPaths(candidateSha);
|
||||
if (await candidateBuildIsReady(candidateSha)) return buildPaths;
|
||||
|
||||
const buildLease = await acquireBuildLease(runRoot, `build:${candidateSha}`);
|
||||
try {
|
||||
if (await candidateBuildIsReady(candidateSha)) return buildPaths;
|
||||
await assertSafeContainedPath(buildPaths.appRoot, buildPaths.buildDir, "QA candidate build directory");
|
||||
await rm(buildPaths.buildDir, { recursive: true, force: true });
|
||||
await assertSafeContainedPath(buildPaths.appRoot, buildPaths.buildDir, "QA candidate build directory");
|
||||
|
||||
runForeground("pnpm", ["--filter", "@homarr/nextjs", "with-env", "next", "build", "--webpack"], {
|
||||
...sanitizeAppEnvironment(process.env),
|
||||
CI: "true",
|
||||
DISABLE_REDIS_LOGS: "true",
|
||||
HOMARR_QA_NEXT_DIST_DIR: buildPaths.distDirName,
|
||||
HOMARR_QA_STANDALONE_BUILD: "true",
|
||||
HOMARR_VERSION: candidateSha.slice(0, 12),
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
NODE_OPTIONS: `--max-old-space-size=${candidateBuildHeapLimitMb}`,
|
||||
SKIP_ENV_VALIDATION: "true",
|
||||
});
|
||||
|
||||
const staticTarget = path.join(buildPaths.standaloneAppDir, ".next/static");
|
||||
const publicTarget = path.join(buildPaths.standaloneAppDir, "public");
|
||||
await mkdir(path.dirname(staticTarget), { recursive: true });
|
||||
await cp(path.join(buildPaths.buildDir, "static"), staticTarget, { recursive: true, force: true });
|
||||
await cp(path.join(buildPaths.appRoot, "public"), publicTarget, { recursive: true, force: true });
|
||||
const serverStats = await stat(buildPaths.serverPath);
|
||||
if (!serverStats.isFile()) throw new Error(`Standalone server is missing after build: ${buildPaths.serverPath}`);
|
||||
await writeJsonAtomic(buildPaths.markerPath, {
|
||||
schemaVersion: 1,
|
||||
candidateSha,
|
||||
runtimeMode,
|
||||
bundler: runtimeBundler,
|
||||
builtAt: new Date().toISOString(),
|
||||
});
|
||||
return buildPaths;
|
||||
} finally {
|
||||
await buildLease.release();
|
||||
}
|
||||
};
|
||||
|
||||
const isProcessRunning = (pid: number) => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
@@ -503,6 +597,7 @@ const startWithLease = async (options: Options) => {
|
||||
} else {
|
||||
await assertRunRootMarker(options.runRoot);
|
||||
}
|
||||
const candidateBuild = await ensureCandidateBuild(options.runRoot, candidateSha);
|
||||
|
||||
const slotsRoot = path.join(options.runRoot, "slots");
|
||||
await assertSafeContainedPath(options.runRoot, slotsRoot, "QA slots directory");
|
||||
@@ -525,11 +620,19 @@ const startWithLease = async (options: Options) => {
|
||||
await rm(slotDir, { recursive: true });
|
||||
await assertSafeContainedPath(options.runRoot, slotDir, "QA slot reset target");
|
||||
}
|
||||
const nextDistDir = path.join(repoRoot, "apps/nextjs/.next-qa", `slot-${options.slot}`);
|
||||
const legacyNextDistDir = path.join(repoRoot, "apps/nextjs/.next-qa", `slot-${options.slot}`);
|
||||
if (options.reset) {
|
||||
await assertSafeContainedPath(path.join(repoRoot, "apps/nextjs"), nextDistDir, "QA Next cache reset target");
|
||||
await rm(nextDistDir, { recursive: true, force: true });
|
||||
await assertSafeContainedPath(path.join(repoRoot, "apps/nextjs"), nextDistDir, "QA Next cache reset target");
|
||||
await assertSafeContainedPath(
|
||||
path.join(repoRoot, "apps/nextjs"),
|
||||
legacyNextDistDir,
|
||||
"legacy QA Next cache reset target",
|
||||
);
|
||||
await rm(legacyNextDistDir, { recursive: true, force: true });
|
||||
await assertSafeContainedPath(
|
||||
path.join(repoRoot, "apps/nextjs"),
|
||||
legacyNextDistDir,
|
||||
"legacy QA Next cache reset target",
|
||||
);
|
||||
}
|
||||
await assertSafeContainedPath(options.runRoot, slotDir, "QA slot creation target");
|
||||
await mkdir(slotDir, { mode: 0o700 });
|
||||
@@ -619,7 +722,7 @@ const startWithLease = async (options: Options) => {
|
||||
HOMARR_WEBSITE_URL: url,
|
||||
WORKSHOP_API_URL: fixtureReady.url,
|
||||
WORKSHOP_WEB_URL: `${fixtureReady.url}/iframe`,
|
||||
HOMARR_QA_NEXT_DIST_DIR: `.next-qa/slot-${options.slot}`,
|
||||
HOMARR_QA_NEXT_DIST_DIR: candidateBuild.distDirName,
|
||||
HOMARR_VERSION: candidateSha.slice(0, 12),
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
};
|
||||
@@ -631,24 +734,30 @@ const startWithLease = async (options: Options) => {
|
||||
runForeground("pnpm", ["qa:release-v2:seed", "--", "--profile", options.profile, "--output", slotDir], environment);
|
||||
await assertSafeContainedPath(options.runRoot, fixtureManifestPath, "QA fixture manifest");
|
||||
|
||||
const appEnvironment = {
|
||||
...sanitizeAppEnvironment(environment),
|
||||
WATCHPACK_POLLING: String(watchpackPollingIntervalMs),
|
||||
};
|
||||
const appEnvironment = sanitizeAppEnvironment(environment);
|
||||
await assertSafeContainedPath(options.runRoot, appLogPath, "QA app log");
|
||||
const launchedApp = await launchWithPortRetry<{ pid: number; port: number; url: string }>(
|
||||
async (attemptPort) => {
|
||||
appPort = attemptPort;
|
||||
url = `http://127.0.0.1:${appPort}`;
|
||||
const appLaunch = createAppLaunchCommand(appPort);
|
||||
const appLaunch = createAppLaunchCommand(candidateBuild.serverPath);
|
||||
const logOffset = await stat(appLogPath)
|
||||
.then((stats) => stats.size)
|
||||
.catch(() => 0);
|
||||
await assertSafeContainedPath(options.runRoot, appLogPath, "QA app log");
|
||||
const spawned = spawnLogged(appLaunch.command, appLaunch.arguments, appLogPath, {
|
||||
...appEnvironment,
|
||||
HOMARR_WEBSITE_URL: url,
|
||||
});
|
||||
const spawned = spawnLogged(
|
||||
appLaunch.command,
|
||||
appLaunch.arguments,
|
||||
appLogPath,
|
||||
{
|
||||
...appEnvironment,
|
||||
HOSTNAME: "127.0.0.1",
|
||||
HOMARR_WEBSITE_URL: url,
|
||||
NODE_ENV: "production",
|
||||
PORT: String(appPort),
|
||||
},
|
||||
{ cwd: candidateBuild.standaloneRoot },
|
||||
);
|
||||
appPid = spawned.pid;
|
||||
const outcome = await waitForAppAttempt(spawned, url, appLogPath, logOffset, 300_000);
|
||||
if (outcome === "address-in-use") {
|
||||
@@ -678,12 +787,13 @@ const startWithLease = async (options: Options) => {
|
||||
slotDir,
|
||||
dbPath,
|
||||
fixtureManifestPath,
|
||||
nextDistDir,
|
||||
nextDistDir: candidateBuild.buildDir,
|
||||
url,
|
||||
fixtureUrl: fixtureReady.url,
|
||||
ports: { app: appPort, fixture: fixtureReady.port, redis: redisPort },
|
||||
flags,
|
||||
...createRuntimeExecutionContract(),
|
||||
build: { candidateSha, serverPath: candidateBuild.serverPath },
|
||||
processes: {
|
||||
app: { pid: appPid, logPath: appLogPath },
|
||||
fixture: { pid: fixturePid, logPath: fixtureLogPath },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
assertRuntimeCommandPlatform,
|
||||
createAppLaunchCommand,
|
||||
createCandidateBuildPaths,
|
||||
createRuntimeExecutionContract,
|
||||
launchWithPortRetry,
|
||||
reserveLoopbackPort,
|
||||
@@ -88,44 +89,58 @@ describe("release-v2 QA runtime platform boundary", () => {
|
||||
});
|
||||
|
||||
describe("release-v2 QA app execution contract", () => {
|
||||
test("uses Webpack dev while preserving the Linux file-descriptor wrapper", () => {
|
||||
const launch = createAppLaunchCommand(31_337, { platform: "linux", prlimitAvailable: true });
|
||||
const launchWithoutPrlimit = createAppLaunchCommand(31_337, { platform: "linux", prlimitAvailable: false });
|
||||
test("uses the candidate standalone server while preserving the Linux file-descriptor wrapper", () => {
|
||||
const serverPath = "/tmp/release-v2-build/standalone/apps/nextjs/server.js";
|
||||
const launch = createAppLaunchCommand(serverPath, {
|
||||
platform: "linux",
|
||||
prlimitAvailable: true,
|
||||
nodeExecutable: "/usr/bin/node",
|
||||
});
|
||||
const launchWithoutPrlimit = createAppLaunchCommand(serverPath, {
|
||||
platform: "linux",
|
||||
prlimitAvailable: false,
|
||||
nodeExecutable: "/usr/bin/node",
|
||||
});
|
||||
|
||||
expect(launch).toEqual({
|
||||
command: "/usr/bin/prlimit",
|
||||
arguments: [
|
||||
"--nofile=65536:1048576",
|
||||
"--",
|
||||
"pnpm",
|
||||
"--filter",
|
||||
"@homarr/nextjs",
|
||||
"dev",
|
||||
"--webpack",
|
||||
"--hostname",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"31337",
|
||||
],
|
||||
arguments: ["--nofile=65536:1048576", "--", "/usr/bin/node", serverPath],
|
||||
});
|
||||
expect(launchWithoutPrlimit.command).toBe("pnpm");
|
||||
expect(launchWithoutPrlimit.arguments).toContain("--webpack");
|
||||
expect(launchWithoutPrlimit).toEqual({ command: "/usr/bin/node", arguments: [serverPath] });
|
||||
});
|
||||
|
||||
test("records and validates the deterministic bundler and watcher settings", () => {
|
||||
test("records and validates the deterministic standalone build settings", () => {
|
||||
const execution = createRuntimeExecutionContract();
|
||||
|
||||
expect(execution).toEqual({
|
||||
runtimeMode: "production-standalone",
|
||||
bundler: "webpack",
|
||||
watcher: { watchpackPollingIntervalMs: 1_000 },
|
||||
watcher: null,
|
||||
});
|
||||
expect(validateRuntimeExecutionContract(execution)).toEqual([]);
|
||||
expect(
|
||||
validateRuntimeExecutionContract({
|
||||
runtimeMode: "development",
|
||||
bundler: "turbopack",
|
||||
watcher: { watchpackPollingIntervalMs: 500 },
|
||||
}),
|
||||
).toEqual(["bundler must be webpack", "Watchpack polling interval must be 1000ms"]);
|
||||
expect(validateRuntimeExecutionContract({ watcher: execution.watcher })).toEqual(["bundler must be webpack"]);
|
||||
).toEqual([
|
||||
"runtime mode must be production-standalone",
|
||||
"bundler must be webpack",
|
||||
"standalone runtimes must not use a filesystem watcher",
|
||||
]);
|
||||
expect(validateRuntimeExecutionContract({ watcher: execution.watcher })).toEqual([
|
||||
"runtime mode must be production-standalone",
|
||||
"bundler must be webpack",
|
||||
]);
|
||||
});
|
||||
|
||||
test("derives one candidate-pinned build shared by every runtime slot", () => {
|
||||
const paths = createCandidateBuildPaths("0123456789abcdef0123456789abcdef01234567");
|
||||
|
||||
expect(paths.distDirName).toBe(".next-qa/release-v2-0123456789abcdef0123456789abcdef01234567");
|
||||
expect(paths.serverPath).toBe(`${paths.buildDir}/standalone/apps/nextjs/server.js`);
|
||||
expect(paths.standaloneRoot).toBe(`${paths.buildDir}/standalone`);
|
||||
expect(() => createCandidateBuildPaths("not-a-commit")).toThrow(/40-character lowercase commit SHA/u);
|
||||
});
|
||||
});
|
||||
@@ -179,6 +179,12 @@ export const getSlotLeasePath = async (runRoot: string, slot: number) => {
|
||||
return path.join(canonicalParent, `.${path.basename(canonicalRunRoot)}.slot-${slot}.lease`);
|
||||
};
|
||||
|
||||
export const getBuildLeasePath = async (runRoot: string) => {
|
||||
const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true });
|
||||
const canonicalParent = await canonicalTemporaryParent();
|
||||
return path.join(canonicalParent, `.${path.basename(canonicalRunRoot)}.candidate-build.lease`);
|
||||
};
|
||||
|
||||
const readLeaseSnapshot = async (leasePath: string): Promise<LeaseSnapshot> => {
|
||||
const stats = await lstat(leasePath, { bigint: true });
|
||||
if (stats.isSymbolicLink() || !stats.isFile()) {
|
||||
@@ -234,16 +240,16 @@ const recoverStaleLease = async (leasePath: string, snapshot: LeaseSnapshot) =>
|
||||
await unlink(leasePath);
|
||||
};
|
||||
|
||||
export const acquireSlotLease = async (
|
||||
const acquireLease = async (
|
||||
runRoot: string,
|
||||
slot: number,
|
||||
operation: string,
|
||||
leasePath: string,
|
||||
resourceLabel: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<SlotLease> => {
|
||||
assertSupportedRuntimePlatform(platform);
|
||||
if (![1, 2, 3].includes(slot)) throw new Error("QA slot lease requires slot 1, 2, or 3");
|
||||
const canonicalRunRoot = await assertSafeRunRoot(runRoot, { allowMissing: true });
|
||||
const leasePath = await getSlotLeasePath(canonicalRunRoot, slot);
|
||||
const owner: SlotLeaseOwner = {
|
||||
schemaVersion: leaseSchemaVersion,
|
||||
pid: process.pid,
|
||||
@@ -271,7 +277,7 @@ export const acquireSlotLease = async (
|
||||
release: async () => {
|
||||
const snapshot = await readLeaseSnapshot(leasePath);
|
||||
if (!isValidLeaseOwner(snapshot.owner, canonicalRunRoot, slot) || snapshot.owner?.token !== owner.token) {
|
||||
throw new Error(`Refusing to release a QA slot lease owned by another process: ${leasePath}`);
|
||||
throw new Error(`Refusing to release a QA ${resourceLabel} lease owned by another process: ${leasePath}`);
|
||||
}
|
||||
await recoverStaleLease(leasePath, snapshot);
|
||||
},
|
||||
@@ -282,21 +288,44 @@ export const acquireSlotLease = async (
|
||||
const existingOwner = snapshot.owner;
|
||||
if (!isValidLeaseOwner(existingOwner, canonicalRunRoot, slot)) {
|
||||
if (Date.now() - snapshot.modifiedMs < incompleteLeaseGraceMs) {
|
||||
throw new Error(`QA slot ${slot} lease is being initialized by another operation`, { cause: error });
|
||||
throw new Error(`QA ${resourceLabel} lease is being initialized by another operation`, { cause: error });
|
||||
}
|
||||
await recoverStaleLease(leasePath, snapshot);
|
||||
continue;
|
||||
}
|
||||
if (await processMatchesLeaseOwner(existingOwner)) {
|
||||
throw new Error(`QA slot ${slot} is busy with ${existingOwner.operation} owned by PID ${existingOwner.pid}`, {
|
||||
cause: error,
|
||||
});
|
||||
throw new Error(
|
||||
`QA ${resourceLabel} is busy with ${existingOwner.operation} owned by PID ${existingOwner.pid}`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
await recoverStaleLease(leasePath, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to acquire QA slot ${slot} lease after stale recovery`);
|
||||
throw new Error(`Unable to acquire QA ${resourceLabel} lease after stale recovery`);
|
||||
};
|
||||
|
||||
export const acquireSlotLease = async (
|
||||
runRoot: string,
|
||||
slot: number,
|
||||
operation: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
) => {
|
||||
if (![1, 2, 3].includes(slot)) throw new Error("QA slot lease requires slot 1, 2, or 3");
|
||||
const leasePath = await getSlotLeasePath(runRoot, slot);
|
||||
return await acquireLease(runRoot, slot, operation, leasePath, `slot ${slot}`, platform);
|
||||
};
|
||||
|
||||
export const acquireBuildLease = async (
|
||||
runRoot: string,
|
||||
operation: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
) => {
|
||||
const leasePath = await getBuildLeasePath(runRoot);
|
||||
return await acquireLease(runRoot, 0, operation, leasePath, "candidate build", platform);
|
||||
};
|
||||
|
||||
const normalizedHost = (host: string) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
acquireBuildLease,
|
||||
acquireSlotLease,
|
||||
assertSafeContainedPath,
|
||||
assertSafeRunRoot,
|
||||
@@ -116,6 +117,17 @@ describe("release-v2 QA harness safety", () => {
|
||||
await lease.release();
|
||||
});
|
||||
|
||||
test("serializes the shared candidate build independently of slot leases", async () => {
|
||||
const runRoot = path.join(tmpdir(), uniqueName("build-lease"));
|
||||
const buildLease = await acquireBuildLease(runRoot, "first-build");
|
||||
const slotLease = await acquireSlotLease(runRoot, 1, "slot-start");
|
||||
temporaryPaths.push(buildLease.path, slotLease.path);
|
||||
|
||||
await expect(acquireBuildLease(runRoot, "second-build")).rejects.toThrow(/candidate build is busy.*first-build/u);
|
||||
await buildLease.release();
|
||||
await slotLease.release();
|
||||
});
|
||||
|
||||
test("recovers a stale lease with a dead owner PID", async () => {
|
||||
const runRoot = path.join(tmpdir(), uniqueName("stale-lease"));
|
||||
const leasePath = await getSlotLeasePath(runRoot, 2);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { findFixtureGeometryErrors } from "./geometry.mts";
|
||||
import { resolveCheckoutCandidateSha } from "./provenance.mts";
|
||||
import { collectHumanWidgetStatusErrors, normalizeReportMetadata } from "./report-integrity.mts";
|
||||
import { assertSafeReportPath, readSafeReportFile, validateResolvedArtifactPath } from "./report-path-integrity.mts";
|
||||
import { validateRuntimeExecutionContract } from "./runtime.mts";
|
||||
import { createCandidateBuildPaths, validateRuntimeExecutionContract } from "./runtime.mts";
|
||||
|
||||
import { assertSafeRunRoot, isPathWithin, validateFixtureUrl } from "./safety.mts";
|
||||
|
||||
@@ -118,8 +118,10 @@ interface RuntimeManifest {
|
||||
fixtureUrl: string;
|
||||
ports: { app: number; fixture: number; redis: number };
|
||||
flags: { demoMode: boolean; demoReadOnly: boolean; unsafeMockIntegration: boolean };
|
||||
runtimeMode: string;
|
||||
bundler: string;
|
||||
watcher: { watchpackPollingIntervalMs: number };
|
||||
watcher: unknown;
|
||||
build: { candidateSha: string; serverPath: string };
|
||||
processes: {
|
||||
app: { pid: number; logPath: string };
|
||||
fixture: { pid: number; logPath: string };
|
||||
@@ -645,7 +647,7 @@ const validateRuntimeManifest = async (
|
||||
const expectedManifestPath = join(expectedSlotDir, "runtime-manifest.json");
|
||||
const expectedDbPath = join(expectedSlotDir, "db.sqlite");
|
||||
const expectedFixtureManifestPath = join(expectedSlotDir, "fixture-manifest.json");
|
||||
const expectedNextDistDir = join(repoRoot, "apps/nextjs/.next-qa", `slot-${runtime.slot}`);
|
||||
const expectedBuild = createCandidateBuildPaths(campaignCandidateSha);
|
||||
if (resolve(runtime.slotDir) !== resolve(expectedSlotDir))
|
||||
errors.push(`${label}: slotDir is outside the selected slot`);
|
||||
if (resolve(manifestPath) !== resolve(expectedManifestPath))
|
||||
@@ -654,8 +656,14 @@ const validateRuntimeManifest = async (
|
||||
if (resolve(runtime.fixtureManifestPath) !== resolve(expectedFixtureManifestPath)) {
|
||||
errors.push(`${label}: fixture manifest path is outside the selected slot`);
|
||||
}
|
||||
if (resolve(runtime.nextDistDir) !== resolve(expectedNextDistDir)) {
|
||||
errors.push(`${label}: Next dist directory is not isolated to the selected slot`);
|
||||
if (resolve(runtime.nextDistDir) !== resolve(expectedBuild.buildDir)) {
|
||||
errors.push(`${label}: Next dist directory is not the candidate-pinned shared build`);
|
||||
}
|
||||
if (
|
||||
runtime.build?.candidateSha !== campaignCandidateSha ||
|
||||
resolve(runtime.build?.serverPath ?? "") !== resolve(expectedBuild.serverPath)
|
||||
) {
|
||||
errors.push(`${label}: standalone build metadata does not match the campaign candidate`);
|
||||
}
|
||||
for (const processRecord of Object.values(runtime.processes ?? {})) {
|
||||
if (!isPathWithin(expectedSlotDir, resolve(processRecord.logPath))) {
|
||||
|
||||
Reference in new issue
Block a user