test: run release v2 QA from shared standalone build

This commit is contained in:
ajnart committed 2026-09-01 06:23:19 +02:00
1 parent b40468f37c
commit a149dbdfab
9 files changed
+266 -91

No files matched your search

+7 -6
View File
@@ -16,9 +16,10 @@ const withNextIntl = createNextIntlPlugin({
});
const developmentServicePackages = ["@homarr/tasks", "@homarr/websocket"] as const;
const isQaStandaloneBuild = process.env.HOMARR_QA_STANDALONE_BUILD === "true";
const getDevelopmentServiceAliases = (target: string) => {
if (process.env.NODE_ENV !== "development") {
if (process.env.NODE_ENV !== "development" && !isQaStandaloneBuild) {
return undefined;
}
@@ -28,8 +29,8 @@ const getDevelopmentServiceAliases = (target: string) => {
const getDistDir = () => {
const qaDistDir = process.env.HOMARR_QA_NEXT_DIST_DIR;
if (!qaDistDir) return ".next";
if (!/^\.next-qa\/slot-[1-3]$/u.test(qaDistDir)) {
throw new Error("HOMARR_QA_NEXT_DIST_DIR must identify .next-qa/slot-1, slot-2, or slot-3");
if (!/^\.next-qa\/(?:slot-[1-3]|release-v2-[0-9a-f]{40})$/u.test(qaDistDir)) {
throw new Error("HOMARR_QA_NEXT_DIST_DIR must identify a QA slot or candidate-pinned release-v2 build");
}
return qaDistDir;
};
@@ -62,12 +63,12 @@ const nextConfig: NextConfig = {
},
turbopack: {
root: path.resolve(import.meta.dirname, "../.."),
// Development runs tasks and WebSocket as separate processes. These aliases
// keep their production-only instrumentation imports out of the dev graph.
// Development and the isolated QA standalone build do not embed tasks or WebSocket.
// These aliases keep their production-only instrumentation imports out of those graphs.
resolveAlias: getDevelopmentServiceAliases("./src/instrumentation-noop.ts"),
},
webpack(config, { dev, isServer }) {
if (!dev || !isServer) return config;
if ((!dev && !isQaStandaloneBuild) || !isServer) return config;
const instrumentationNoopPath = path.resolve(import.meta.dirname, "src/instrumentation-noop.ts");
const aliases = getDevelopmentServiceAliases(instrumentationNoopPath);
+3 -3
View File
@@ -36,7 +36,7 @@ pnpm qa:release-v2:runtime -- status --slot 1
pnpm qa:release-v2:runtime -- stop --slot 1
```
Slots `1`, `2`, and `3` have separate database files, Redis containers and ports, cookie prefixes, logs, fixture ports, app ports, and `.next-qa/slot-<n>` build directories. The runtime manifest is written only after the app is ready and records the watcher interval. Status checks the owned processes, app, fixture, and Redis. Stop is idempotent for a stopped manifest and refuses to signal a live PID that does not carry the recorded QA run ID.
Slots `1`, `2`, and `3` have separate database files, Redis containers and ports, cookie prefixes, logs, fixture ports, and app ports. They reuse one immutable candidate-pinned production standalone build under `.next-qa/release-v2-<candidate-sha>`, so three active profiles do not each retain a multi-gigabyte development compiler. The runtime manifest is written only after the standalone app is ready and records the exact shared build and server path. Status checks the owned processes, app, fixture, and Redis. Stop is idempotent for a stopped manifest and refuses to signal a live PID that does not carry the recorded QA run ID.
Every start, reset, and stop holds an exclusive per-slot filesystem lease outside the resettable slot tree. A second operation on the same slot fails deterministically. Stale recovery compares both the recorded PID and its operating-system process-start marker; it never signals a lease owner or an unrelated process. An incomplete fresh lease is preserved for a short grace period instead of being guessed stale.
@@ -44,9 +44,9 @@ The fixture child receives an explicit environment allowlist containing only its
The selected app port remains bound by a temporary loopback reservation through migration and seeding. The reservation is released immediately before Next.js binds. If another process wins that unavoidable release-to-bind window and Next.js reports `EADDRINUSE`, startup retries with a newly reserved, dynamically selected port. No application port is hard-coded.
`--reset` is intentionally narrow: after validating the QA run-root marker, it stops only the selected running manifest, removes only that slot directory, and clears only that slot's `.next-qa` cache. The run root must be a direct child of the canonical operating-system temporary directory returned by Node, its basename must be `homarr-release-v2-qa` or start with `homarr-release-v2-qa-`, and only safe suffix characters are accepted. Temporary-directory aliases are canonicalized before containment checks. Existing run roots and slot paths must not be symlinks or contain symlink components, and destructive paths are rechecked immediately before mutation. The Linux default `/tmp/homarr-release-v2-qa` remains unchanged.
`--reset` is intentionally narrow: after validating the QA run-root marker, it stops only the selected running manifest, removes only that slot directory, and clears only the selected slot's legacy development cache. It never removes the shared candidate build while another slot may use it. The run root must be a direct child of the canonical operating-system temporary directory returned by Node, its basename must be `homarr-release-v2-qa` or start with `homarr-release-v2-qa-`, and only safe suffix characters are accepted. Temporary-directory aliases are canonicalized before containment checks. Existing run roots and slot paths must not be symlinks or contain symlink components, and destructive paths are rechecked immediately before mutation. The Linux default `/tmp/homarr-release-v2-qa` remains unchanged.
This host's `fs.inotify.max_user_instances=128` is too low for concurrent native Watchpack watchers. Spawned QA apps use Next.js Webpack development mode (`--webpack`) and set `WATCHPACK_POLLING=1000`, avoiding reliance on another inotify instance while keeping all three isolated slots available. Webpack also gives clean browser sessions a deterministic development chunk graph instead of retaining stale Turbopack chunk URLs. Filesystem changes can take up to one polling interval to reach HMR. The runtime manifest records `bundler: "webpack"`, and live verification rejects another bundler. Existing slots must be restarted before they satisfy this contract.
This host's `fs.inotify.max_user_instances=128` is too low for concurrent development watchers, and three Webpack development compilers exceed the available memory. The runtime therefore creates one candidate-pinned production standalone build with `next build --webpack` and reuses it across the three isolated slots. The shared build has no watcher or HMR surface, eliminating stale development chunks and keeping each running app lightweight. Source changes require a commit and a new candidate build before browser evidence is valid. Provision slots sequentially when the shared build does not exist yet; once it is present, all three may run concurrently. The runtime manifest records `runtimeMode: "production-standalone"`, `bundler: "webpack"`, `watcher: null`, and the exact build metadata; live verification rejects another contract. Existing development slots must be restarted before they satisfy it.
## Profiles
+3 -3
View File
@@ -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"],
+4 -4
View File
@@ -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");
});
});
+149 -39
View File
@@ -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 },
+37 -22
View File
@@ -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);
});
});
+38 -9
View File
@@ -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) => {
+12
View File
@@ -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);
+13 -5
View File
@@ -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))) {