Compare commits

...

14 Commits

Author SHA1 Message Date
Aidan Timson 3ffddf5f5d Consolidate dev server lifecycle handlers 2026-07-30 16:39:32 +01:00
Aidan Timson f36852c763 Trim workflow lock unit tests 2026-07-30 16:35:48 +01:00
Aidan Timson ce99ff6d7c Focus workflow locking on managed commands 2026-07-30 16:33:33 +01:00
Aidan Timson b81404c7a1 Simplify workflow lock ownership 2026-07-30 15:56:14 +01:00
Aidan Timson 94792cf26b Block concurrent frontend workflows 2026-07-30 15:30:02 +01:00
Aidan Timson 5b7fa44068 Keep test navigator configurable 2026-07-30 14:42:18 +01:00
Aidan Timson 08936cd203 Make generated lock test deterministic 2026-07-30 14:41:47 +01:00
Aidan Timson bda53e8c1d Preserve dev server child failures 2026-07-30 14:41:40 +01:00
Aidan Timson 6504267ac9 Isolate generated inputs for dev servers 2026-07-30 14:41:29 +01:00
Aidan Timson 7e6ca26ad7 Queue shared generated output work 2026-07-30 13:32:35 +01:00
Aidan Timson 7b2d9154f9 Test build management contracts 2026-07-30 12:22:17 +01:00
Aidan Timson d1aff0d8ec Make build workflows deterministic 2026-07-30 12:22:17 +01:00
Aidan Timson 6de4b75fba Harden managed process lifecycle 2026-07-30 12:22:17 +01:00
Aidan Timson 35de5d2d23 Shared build and runner for all build,dev,test flows 2026-07-30 12:22:17 +01:00
15 changed files with 619 additions and 273 deletions
+4 -2
View File
@@ -45,7 +45,9 @@ yarn build --stop
Use `yarn build --modern --background` for production bundle-size or browser performance comparisons that only need modern browser output. It runs the normal metadata and static preparation, minifies and compresses the modern `frontend_latest` bundle and shared static assets, and generates modern-only entry pages and service workers. It deliberately skips the legacy bundle and its service worker.
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. It prevents another foreground or background build from starting while one is using the shared generated files under `build/` and `hass_frontend/`.
Do not pass `--help`, `--background`, or `--modern` to `script/build_frontend`; that raw script does not parse arguments and always starts the full foreground build. Use `yarn build` for managed builds. App builds and development servers keep exclusive ownership of `hass_frontend/` for their lifetime.
Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so only one build or development server can run at a time.
## Unit And Utility Tests
@@ -78,7 +80,7 @@ The custom development wrappers use `/__ha_dev_status` to identify and manage th
Local runs against a watched development server do not always match CI's clean build artifacts, environment, sharding, or worker configuration. Use background servers for the fast iteration loop, but confirm the relevant CI jobs complete successfully before considering E2E changes verified.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites in parallel when every managed server is available, otherwise it runs them sequentially to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs suites sequentially when managed servers are unavailable to prevent cold builds racing over shared generated assets. Run suites directly; piping through output truncation hides progress and failures.
The app suite uses a stripped-down harness for e2e. Demo and gallery use their normal dev servers.
+40 -14
View File
@@ -25,24 +25,26 @@ import {
runCli,
spawnDetachedToLog,
spawnForeground,
terminateDetachedProcess,
terminateProcess,
waitFor,
writeProcessRecord,
} from "./managed-process.mjs";
import {
buildCacheDir,
describeOutputOwner,
workflowLockEnv,
workflowLockFile,
} from "./output-lock.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
const stateDir = path.join(repoRoot, "node_modules", ".cache", "ha-build");
const stateDir = path.join(buildCacheDir, "ha-build");
const logFile = path.join(stateDir, "build.log");
const lockFile = path.join(
repoRoot,
"node_modules",
".cache",
"ha-generated-output.lock"
);
const lockFile = workflowLockFile;
const usage = () => {
process.stderr.write(
@@ -79,6 +81,21 @@ const hints = () =>
" Status: yarn build --status\n" +
" Logs: yarn build --logs\n";
const devCommand = (suite) => {
switch (suite) {
case "app-serve":
return "dev:serve";
case "demo":
return "dev:demo";
case "gallery":
return "dev:gallery";
case "e2e-app":
return "test:e2e:app:dev";
default:
return "dev";
}
};
const readBuild = () => readProcessRecord(lockFile);
const releaseBuild = (token) => releaseProcessRecord(lockFile, token);
@@ -115,8 +132,15 @@ const updateBuild = (token, child, processGroup) => {
const taskFor = (modern) => (modern ? "build-app-modern" : "build-app");
const reportExisting = (existing) => {
if (existing?.kind === "output") {
process.stdout.write(
`${describeOutputOwner(existing)} already owns the build and development workflow` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
);
return;
}
if (existing?.kind === "dev") {
const command = existing.suite === "app-serve" ? "dev:serve" : "dev";
const command = devCommand(existing.suite);
process.stdout.write(
`Dev server (${existing.suite}) already running` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` +
@@ -143,6 +167,7 @@ const runForeground = async (modern) => {
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
processGroup: true,
onSpawn: (child) => updateBuild(lock.token, child, true),
});
@@ -157,11 +182,13 @@ const runBackground = async (modern) => {
reportExisting(lock.existing);
return 1;
}
let child;
try {
const child = await spawnDetachedToLog({
child = await spawnDetachedToLog({
cmd: gulpBin,
args: [taskFor(modern)],
cwd: repoRoot,
env: workflowLockEnv(lock.token),
logFile,
});
updateBuild(lock.token, child, true);
@@ -171,6 +198,9 @@ const runBackground = async (modern) => {
);
return 0;
} catch (err) {
if (child) {
await terminateDetachedProcess(child);
}
releaseBuild(lock.token);
throw err;
}
@@ -245,11 +275,7 @@ const main = async () => {
usage();
return 1;
}
if (
args.modes.length > 1 ||
(args.follow && args.mode !== "logs") ||
(args.modern && !["foreground", "background"].includes(args.mode))
) {
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
process.stderr.write("Invalid combination of build arguments.\n");
usage();
return 1;
+162 -243
View File
@@ -14,13 +14,10 @@
//
// health demo, gallery, e2e-app: a fixed port plus the /__ha_dev_status
// endpoint each dev server exposes (see runDevServer in
// build-scripts/gulp/rspack.js). The port is the source of truth and
// the pid is found from it; no state file.
// process app (yarn dev) and app-serve (yarn dev:serve): the app watcher has
// no health endpoint, and plain yarn dev has no port at all, so these
// track a pidfile and treat the first "Build done" log line as ready.
// build-scripts/gulp/rspack.js).
// process app (yarn dev) and app-serve (yarn dev:serve): plain yarn dev has
// no port, so these treat the first "Build done" log line as ready.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -32,15 +29,20 @@ import {
processStartTime,
readProcessRecord,
releaseProcessRecord,
removeProcessRecord,
runCli,
sleep,
spawnDetachedToLog,
spawnForeground,
terminateDetachedProcess,
terminateProcess,
waitFor,
writeProcessRecord,
} from "./managed-process.mjs";
import {
buildCacheDir,
describeOutputOwner,
workflowLockEnv,
workflowLockFile,
} from "./output-lock.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@@ -52,13 +54,7 @@ const developAndServeScript = path.join(
"script",
"develop_and_serve"
);
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
const outputLockFile = path.join(
repoRoot,
"node_modules",
".cache",
"ha-generated-output.lock"
);
const logDir = path.join(buildCacheDir, "ha-dev-server");
// Each suite names its yarn alias (for hints), a liveness model, and how to
// spawn it. health suites carry a fixed port; process suites carry the log line
@@ -98,7 +94,6 @@ const SUITES = new Map([
liveness: "process",
readyLog: /Build done @/,
spawn: { cmd: gulpBin, args: ["develop-app"] },
processKey: "app",
},
],
[
@@ -109,7 +104,6 @@ const SUITES = new Map([
acceptsArgs: true,
readyLog: /Build done @/,
spawn: { cmd: developAndServeScript, args: [] },
processKey: "app",
},
],
]);
@@ -185,27 +179,7 @@ const parseArgs = (argv) => {
};
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
const pidFileFor = (suite) =>
path.join(logDir, `${SUITES.get(suite).processKey ?? suite}.pid`);
const removePidFileIf = (suite, matches) => {
const existing = readProcessRecord(pidFileFor(suite));
if (!existing) {
removeProcessRecord(pidFileFor(suite));
return true;
}
if (matches(existing)) {
releaseProcessRecord(outputLockFile, existing.token, () => {
if (readProcessRecord(pidFileFor(suite))?.token === existing.token) {
removeProcessRecord(pidFileFor(suite));
}
});
return true;
}
return false;
};
const acquireProcessSuite = (suite) => {
const pidFile = pidFileFor(suite);
const acquireSuite = (suite) => {
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
@@ -215,48 +189,37 @@ const acquireProcessSuite = (suite) => {
starting: true,
token,
};
const result = acquireProcessRecord(outputLockFile, record);
if (!result.acquired) {
return { existing: result.existing };
}
try {
writeProcessRecord(pidFile, record);
} catch (err) {
releaseProcessRecord(outputLockFile, token);
throw err;
}
return { token };
const result = acquireProcessRecord(workflowLockFile, record);
return result.acquired ? { token } : { existing: result.existing };
};
const updateProcessSuite = (suite, token, child) => {
const existing = readPidFile(suite);
const updateSuite = (suite, token, child, port) => {
const existing = readProcessRecord(workflowLockFile);
if (existing?.token !== token) {
throw Error(
`Dev server (${suite}) process ownership was lost during startup.`
);
throw Error(`Dev server (${suite}) ownership was lost during startup.`);
}
const record = {
writeProcessRecord(workflowLockFile, {
...existing,
pid: child.pid,
startTime: processStartTime(child.pid),
processGroup: true,
starting: false,
};
writePidFile(suite, record);
const outputOwner = readProcessRecord(outputLockFile);
if (outputOwner?.token !== token) {
throw Error(
`Dev server (${suite}) output ownership was lost during startup.`
);
}
writeProcessRecord(outputLockFile, record);
port,
});
};
const releaseProcessSuite = (suite, token) => {
releaseProcessRecord(outputLockFile, token, () => {
if (readPidFile(suite)?.token === token) {
removeProcessRecord(pidFileFor(suite));
}
});
const releaseSuite = (token) => releaseProcessRecord(workflowLockFile, token);
const readSuite = (suite) => {
const existing = readProcessRecord(workflowLockFile);
if (existing?.kind !== "dev" || existing.suite !== suite) {
return undefined;
}
if (isProcessRecordAlive(existing)) {
return existing;
}
releaseSuite(existing.token);
return undefined;
};
const hints = (suite) => {
@@ -269,6 +232,13 @@ const hints = (suite) => {
};
const reportProcessConflict = (suite, existing) => {
if (existing?.kind === "output") {
process.stdout.write(
`${describeOutputOwner(existing)} already owns the app output` +
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
);
return;
}
if (existing?.kind === "build") {
process.stdout.write(
`Frontend build already running${existing.pid ? ` (pid ${existing.pid})` : ""}. ` +
@@ -284,6 +254,22 @@ const reportProcessConflict = (suite, existing) => {
);
};
const acquireSuiteForStart = (suite) => {
const lock = acquireSuite(suite);
if (lock.token) {
return lock;
}
reportProcessConflict(suite, lock.existing);
return {
code:
lock.existing?.kind === "dev" &&
lock.existing.suite === suite &&
!lock.existing.starting
? 0
: 1,
};
};
// --- shared spawning and lifecycle ------------------------------------------
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
@@ -394,163 +380,116 @@ const probe = async (port, timeoutMs = 1000) => {
return probeHost(0, false);
};
// Find the pid listening on a port via the first available tool (no state file).
const pidFromPort = (port) => {
const attempts = [
[
"lsof",
["-ti", `tcp:${port}`, "-sTCP:LISTEN"],
(out) => out.trim().split("\n")[0],
],
[
"ss",
["-ltnpH", `sport = :${port}`],
(out) => out.match(/pid=(\d+)/)?.[1],
],
["fuser", [`${port}/tcp`], (out) => out.trim().split(/\s+/)[0]],
];
for (const [cmd, cmdArgs, extract] of attempts) {
const isHttpServing = async (port, timeoutMs = 1000) => {
const probeHost = async (index) => {
const host = PROBE_HOSTS[index];
if (!host) {
return false;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const out = execFileSync(cmd, cmdArgs, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
const response = await fetch(`http://${host}:${port}`, {
signal: controller.signal,
});
const pid = Number(extract(out));
if (Number.isInteger(pid) && pid > 0) {
return pid;
if (response.ok) {
return true;
}
} catch {
// Try the next tool.
// Try the next address.
} finally {
clearTimeout(timer);
}
}
return undefined;
return probeHost(index + 1);
};
return probeHost(0);
};
const runForegroundHealth = async (suite, cfg) => {
const { port } = cfg;
const status = await probe(port);
if (status.state === "ours" && status.suite === suite) {
process.stdout.write(
`Dev server (${suite}) is already running at http://localhost:${port}\n`
);
return 0;
const lock = acquireSuiteForStart(suite);
if (!lock.token) {
return lock.code;
}
const status = await probe(port);
if (status.state === "ours") {
releaseSuite(lock.token);
process.stderr.write(
`Port ${port} is serving the ${status.suite ?? "unknown"} dev server; not ${suite}.\n`
`Port ${port} is already serving the ${status.suite ?? "unknown"} dev server.\n`
);
return 1;
}
if (status.state === "foreign") {
releaseSuite(lock.token);
process.stderr.write(
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
);
return 1;
}
return spawnForeground({
cmd: cfg.spawn.cmd,
args: cfg.spawn.args,
cwd: repoRoot,
});
try {
return await spawnForeground({
cmd: cfg.spawn.cmd,
args: cfg.spawn.args,
cwd: repoRoot,
env: workflowLockEnv(lock.token),
processGroup: true,
onSpawn: (child) => updateSuite(suite, lock.token, child, port),
});
} finally {
releaseSuite(lock.token);
}
};
const runBackgroundHealth = async (suite, cfg) => {
const { port } = cfg;
const preflight = await probe(port);
if (preflight.state === "ours" && preflight.suite === suite) {
const pid = pidFromPort(port);
process.stdout.write(
`Dev server (${suite}) already running at http://localhost:${port}` +
`${pid ? ` (pid ${pid})` : ""}\n${hints(suite)}`
);
return 0;
const lock = acquireSuiteForStart(suite);
if (!lock.token) {
return lock.code;
}
const preflight = await probe(port);
if (preflight.state === "ours") {
releaseSuite(lock.token);
process.stderr.write(
`Port ${port} is serving the ${preflight.suite ?? "unknown"} dev server; not ${suite}.\n`
`Port ${port} is already serving the ${preflight.suite ?? "unknown"} dev server.\n`
);
return 1;
}
if (preflight.state === "foreign") {
releaseSuite(lock.token);
process.stderr.write(
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
);
return 1;
}
const logFile = logFileFor(suite);
const child = await spawnDetachedToLog({
cmd: cfg.spawn.cmd,
args: cfg.spawn.args,
cwd: repoRoot,
logFile,
});
return awaitReady({
suite,
child,
logFile,
port,
isReady: async () => {
const status = await probe(port, 1000);
return status.state === "ours" && status.suite === suite;
},
});
};
const runStatusHealth = async (suite, cfg) => {
const { port } = cfg;
const status = await probe(port);
if (status.state === "ours" && status.suite === suite) {
const pid = pidFromPort(port);
process.stdout.write(
`Dev server (${suite}) running at http://localhost:${port}` +
`${pid ? ` (pid ${pid})` : ""}\n`
);
} else if (status.state === "ours") {
process.stdout.write(
`Port ${port} is serving a different Home Assistant frontend dev server (suite ${status.suite ?? "unknown"}); not ${suite}.\n`
);
} else if (status.state === "foreign") {
process.stdout.write(
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
);
} else {
process.stdout.write(`Dev server (${suite}) not running.\n`);
let child;
try {
const logFile = logFileFor(suite);
child = await spawnDetachedToLog({
cmd: cfg.spawn.cmd,
args: cfg.spawn.args,
cwd: repoRoot,
env: workflowLockEnv(lock.token),
logFile,
});
updateSuite(suite, lock.token, child, port);
return awaitReady({
suite,
child,
logFile,
port,
isReady: async () => {
const status = await probe(port, 1000);
return status.state === "ours" && status.suite === suite;
},
onExit: () => releaseSuite(lock.token),
});
} catch (err) {
if (child) {
await terminateDetachedProcess(child);
}
releaseSuite(lock.token);
throw err;
}
return 0;
};
const runStopHealth = async (suite, cfg) => {
const { port } = cfg;
const status = await probe(port);
if (!(status.state === "ours" && status.suite === suite)) {
// Idempotent: stopping something that is not running is a success.
process.stdout.write(`Dev server (${suite}) not running.\n`);
return 0;
}
const pid = pidFromPort(port);
if (!pid) {
process.stderr.write(
`Dev server (${suite}) is running but its pid could not be found ` +
`(no lsof/ss/fuser?). Stop it manually.\n`
);
return 1;
}
return terminate(
suite,
pid,
async () => (await probe(port, 800)).state === "free"
);
};
// --- process liveness (pidfile + log-readiness) -----------------------------
const readPidFile = (suite) => {
return readProcessRecord(pidFileFor(suite));
};
const writePidFile = (suite, data) => {
writeProcessRecord(pidFileFor(suite), data);
};
const logIsReady = (logFile, readyLog) => {
@@ -582,101 +521,79 @@ const spawnArgs = (cfg, passthrough) => [
];
const runForegroundProcess = async (suite, cfg, passthrough) => {
const lock = acquireProcessSuite(suite);
const lock = acquireSuiteForStart(suite);
if (!lock.token) {
reportProcessConflict(suite, lock.existing);
return 0;
return lock.code;
}
try {
return await spawnForeground({
cmd: cfg.spawn.cmd,
args: spawnArgs(cfg, passthrough),
cwd: repoRoot,
env: workflowLockEnv(lock.token),
processGroup: true,
onSpawn: (child) => updateProcessSuite(suite, lock.token, child),
onSpawn: (child) => updateSuite(suite, lock.token, child),
});
} finally {
releaseProcessSuite(suite, lock.token);
releaseSuite(lock.token);
}
};
const runBackgroundProcess = async (suite, cfg, passthrough) => {
const lock = acquireProcessSuite(suite);
const lock = acquireSuiteForStart(suite);
if (!lock.token) {
reportProcessConflict(suite, lock.existing);
return 0;
return lock.code;
}
let child;
try {
const logFile = logFileFor(suite);
const child = await spawnDetachedToLog({
child = await spawnDetachedToLog({
cmd: cfg.spawn.cmd,
args: spawnArgs(cfg, passthrough),
cwd: repoRoot,
env: workflowLockEnv(lock.token),
logFile,
});
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
updateProcessSuite(suite, lock.token, child);
writePidFile(suite, { ...readPidFile(suite), port });
updateSuite(suite, lock.token, child, port);
return awaitReady({
suite,
child,
logFile,
port,
isReady: () => logIsReady(logFile, cfg.readyLog),
onExit: () => releaseProcessSuite(suite, lock.token),
isReady: async () =>
logIsReady(logFile, cfg.readyLog) &&
(!cfg.acceptsArgs || (await isHttpServing(port))),
onExit: () => releaseSuite(lock.token),
});
} catch (err) {
releaseProcessSuite(suite, lock.token);
if (child) {
await terminateDetachedProcess(child);
}
releaseSuite(lock.token);
throw err;
}
};
const runStatusProcess = async (suite) => {
const existing = readPidFile(suite);
if (existing && isProcessRecordAlive(existing)) {
const runStatusSuite = async (suite, cfg) => {
const existing = readSuite(suite);
if (existing) {
process.stdout.write(
`Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.port)} ` +
`Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.port ?? cfg.port)} ` +
`(pid ${existing.pid})\n`
);
} else {
if (existing) {
removePidFileIf(
suite,
(current) =>
current.token === existing.token && !isProcessRecordAlive(current)
);
}
process.stdout.write(`Dev server (${suite}) not running.\n`);
}
return 0;
};
const runStopProcess = async (suite) => {
let existing = readPidFile(suite);
if (existing?.starting) {
const token = existing.token;
await waitFor(
() => {
const current = readPidFile(suite);
return !current?.starting || current.token !== token;
},
100,
5000
);
existing = readPidFile(suite);
}
if (!existing || !isProcessRecordAlive(existing)) {
// Idempotent: stopping something that is not running is a success.
if (existing) {
removePidFileIf(
suite,
(current) =>
current.token === existing.token && !isProcessRecordAlive(current)
);
}
const runStopSuite = async (suite) => {
const existing = readSuite(suite);
if (!existing) {
process.stdout.write(`Dev server (${suite}) not running.\n`);
return 0;
}
@@ -686,14 +603,14 @@ const runStopProcess = async (suite) => {
activeSuite,
pid,
() => !isProcessRecordAlive(existing),
() => releaseProcessSuite(activeSuite, existing.token)
() => releaseSuite(existing.token)
);
};
// --- shared -----------------------------------------------------------------
const runLogs = (suite, follow) => {
const activeSuite = readPidFile(suite)?.suite ?? suite;
const activeSuite = readSuite(suite)?.suite ?? suite;
return outputLog(
logFileFor(activeSuite),
follow,
@@ -739,21 +656,23 @@ const main = async () => {
if (mode === "logs") {
return runLogs(args.suite, args.follow);
}
if (mode === "status") {
return runStatusSuite(args.suite, cfg);
}
if (mode === "stop") {
return runStopSuite(args.suite);
}
const handlers =
cfg.liveness === "health"
? {
foreground: () => runForegroundHealth(args.suite, cfg),
background: () => runBackgroundHealth(args.suite, cfg),
status: () => runStatusHealth(args.suite, cfg),
stop: () => runStopHealth(args.suite, cfg),
}
: {
foreground: () =>
runForegroundProcess(args.suite, cfg, args.passthrough),
background: () =>
runBackgroundProcess(args.suite, cfg, args.passthrough),
status: () => runStatusProcess(args.suite),
stop: () => runStopProcess(args.suite),
};
return handlers[mode]();
};
+4
View File
@@ -1,5 +1,6 @@
import gulp from "gulp";
import env from "../env.cjs";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./compress.js";
import "./entry-html.js";
@@ -17,6 +18,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-app"),
"clean",
gulp.parallel(
"gen-service-worker-app-dev",
@@ -36,6 +38,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app"),
"clean",
gulp.parallel(
"gen-icons-json",
@@ -57,6 +60,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-app-modern"),
"clean",
gulp.parallel(
"gen-icons-json",
+3
View File
@@ -1,4 +1,5 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -13,6 +14,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-demo"),
"clean-demo",
"translations-enable-merge-backend",
gulp.parallel(
@@ -32,6 +34,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-demo"),
"clean-demo",
// Cast needs to be backwards compatible and older HA has no translations
"translations-enable-merge-backend",
+3
View File
@@ -1,4 +1,5 @@
import gulp from "gulp";
import { createWorkflowLockTask } from "../output-lock.mjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -12,6 +13,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
@@ -31,6 +33,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-e2e-test-app"),
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
+3
View File
@@ -4,6 +4,7 @@ import gulp from "gulp";
import { load as loadYaml } from "js-yaml";
import { marked } from "marked";
import path from "path";
import { createWorkflowLockTask } from "../output-lock.mjs";
import paths from "../paths.cjs";
import "./clean.js";
import "./entry-html.js";
@@ -164,6 +165,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "development";
},
createWorkflowLockTask("develop-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
@@ -195,6 +197,7 @@ gulp.task(
async function setEnv() {
process.env.NODE_ENV = "production";
},
createWorkflowLockTask("build-gallery"),
"clean-gallery",
"translations-enable-merge-backend",
gulp.parallel(
+15 -7
View File
@@ -108,7 +108,7 @@ const runDevServer = async ({
}
};
const doneHandler = (done) => (err, stats) => {
const doneHandler = () => (err, stats) => {
if (err) {
log.error(err.stack || err);
if (err.details) {
@@ -122,18 +122,26 @@ const doneHandler = (done) => (err, stats) => {
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
if (done) {
done();
}
};
const prodBuild = (conf) =>
new Promise((resolve) => {
new Promise((resolve, reject) => {
rspack(
conf,
// Resolve promise when done. Because we pass a callback, rspack closes itself
doneHandler(resolve)
(err, stats) => {
if (err) {
reject(err);
} else if (stats.hasErrors()) {
reject(Error(stats.toString("errors-only")));
} else {
if (stats.hasWarnings()) {
console.log(stats.toString("minimal"));
}
log(`Build done @ ${new Date().toLocaleTimeString()}`);
resolve();
}
}
);
});
-2
View File
@@ -1,5 +1,3 @@
/* eslint-disable max-classes-per-file */
import { deleteAsync } from "del";
import { glob } from "glob";
import gulp from "gulp";
+11 -1
View File
@@ -212,6 +212,7 @@ export const spawnForeground = ({
cmd,
args,
cwd,
env,
processGroup = false,
onSpawn,
}) =>
@@ -219,6 +220,7 @@ export const spawnForeground = ({
const child = spawn(cmd, args, {
cwd,
detached: processGroup,
env,
stdio: "inherit",
});
let settled = false;
@@ -265,7 +267,7 @@ export const spawnForeground = ({
});
});
export const spawnDetachedToLog = ({ cmd, args, cwd, logFile }) =>
export const spawnDetachedToLog = ({ cmd, args, cwd, env, logFile }) =>
new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(logFile), { recursive: true });
const fd = fs.openSync(logFile, "w");
@@ -274,6 +276,7 @@ export const spawnDetachedToLog = ({ cmd, args, cwd, logFile }) =>
child = spawn(cmd, args, {
cwd,
detached: true,
env,
stdio: ["ignore", fd, fd],
});
} finally {
@@ -317,6 +320,13 @@ export const terminateProcess = async ({
return await isStopped();
};
export const terminateDetachedProcess = (child) =>
terminateProcess({
pid: child.pid,
processGroup: true,
isStopped: () => !isProcessAlive(child.pid),
});
export const outputLog = (logFile, follow, missingMessage) => {
if (!fs.existsSync(logFile)) {
process.stdout.write(missingMessage);
+127
View File
@@ -0,0 +1,127 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
acquireProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
} from "./managed-process.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
export const buildCacheDir =
process.env.HA_BUILD_CACHE_DIR ??
path.join(repoRoot, "node_modules", ".cache");
const WORKFLOW_LOCK_TOKEN_ENV = "HA_WORKFLOW_LOCK_TOKEN";
const signalCleanups = new Set();
const cleanupSignals = ["SIGINT", "SIGTERM", "SIGHUP"];
const handleSignal = (signal) => {
for (const cleanup of signalCleanups) {
cleanup();
}
for (const cleanupSignal of cleanupSignals) {
process.off(cleanupSignal, handleSignal);
}
process.kill(process.pid, signal);
};
const registerSignalCleanup = (cleanup) => {
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.on(signal, handleSignal);
}
}
signalCleanups.add(cleanup);
};
const unregisterSignalCleanup = (cleanup) => {
signalCleanups.delete(cleanup);
if (signalCleanups.size === 0) {
for (const signal of cleanupSignals) {
process.off(signal, handleSignal);
}
}
};
export const workflowLockFile = path.join(buildCacheDir, "ha-workflow.lock");
export const workflowLockEnv = (token) => ({
...process.env,
[WORKFLOW_LOCK_TOKEN_ENV]: token,
});
export const describeOutputOwner = (owner) => {
if (owner?.kind === "build") {
return `frontend ${owner.modern ? "modern " : ""}build`;
}
if (owner?.kind === "dev") {
return `dev server (${owner.suite ?? "app"})`;
}
return owner?.target ? `Gulp task ${owner.target}` : "another process";
};
const createLockTask = ({ file, inheritedTokenEnv, kind, label, target }) => {
let exitToken;
const cleanup = () => {
if (!exitToken) {
return;
}
releaseProcessRecord(file, exitToken);
exitToken = undefined;
process.off("exit", cleanup);
unregisterSignalCleanup(cleanup);
};
const acquire = async () => {
const inheritedToken = process.env[inheritedTokenEnv];
if (inheritedToken) {
if (readProcessRecord(file)?.token !== inheritedToken) {
throw Error(
`${label} lock ownership was lost before ${target} started.`
);
}
exitToken = inheritedToken;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
return;
}
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
processGroup: false,
kind,
target,
token,
};
const result = acquireProcessRecord(file, record);
if (!result.acquired) {
const pid = result.existing?.pid;
throw Error(
`Cannot run ${target}: ${describeOutputOwner(result.existing)} ` +
`already owns ${label}${pid ? ` (pid ${pid})` : ""}.`
);
}
exitToken = token;
process.once("exit", cleanup);
registerSignalCleanup(cleanup);
};
acquire.displayName = `lock-${label}:${target}`;
return acquire;
};
export const createWorkflowLockTask = (target) =>
createLockTask({
file: workflowLockFile,
inheritedTokenEnv: WORKFLOW_LOCK_TOKEN_ENV,
kind: "output",
label: "build and development workflow",
target,
});
+37 -3
View File
@@ -61,10 +61,44 @@ fi
echo Core is used from ${coreUrl}
# build the frontend so it connects to the passed core
HASS_URL="$coreUrl" ./script/develop &
HASS_URL="$coreUrl" ./node_modules/.bin/gulp develop-app &
develop_pid=$!
# serve the frontend
./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
serve_pid=$!
# keep the script running while serving
wait
stop_children() {
trap - EXIT INT TERM HUP
kill "$develop_pid" "$serve_pid" 2>/dev/null || true
wait "$develop_pid" 2>/dev/null || true
wait "$serve_pid" 2>/dev/null || true
}
trap stop_children EXIT
trap 'stop_children; exit 130' INT
trap 'stop_children; exit 143' TERM
trap 'stop_children; exit 129' HUP
while kill -0 "$develop_pid" 2>/dev/null && kill -0 "$serve_pid" 2>/dev/null; do
sleep 1
done
develop_status=
serve_status=
if ! kill -0 "$develop_pid" 2>/dev/null; then
if wait "$develop_pid"; then develop_status=0; else develop_status=$?; fi
fi
if ! kill -0 "$serve_pid" 2>/dev/null; then
if wait "$serve_pid"; then serve_status=0; else serve_status=$?; fi
fi
if [ -n "$develop_status" ] && [ "$develop_status" -ne 0 ]; then
status=$develop_status
elif [ -n "$serve_status" ]; then
status=$serve_status
else
status=$develop_status
fi
exit "$status"
+139
View File
@@ -0,0 +1,139 @@
/**
* @vitest-environment node
*/
import { execFile, spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { processStartTime } from "../../build-scripts/managed-process.mjs";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../.."
);
const temporaryDirectories = [];
const runNode = (args, env = {}) =>
new Promise((resolve) => {
execFile(
process.execPath,
args,
{
cwd: repoRoot,
env: { ...process.env, ...env },
},
(error, stdout, stderr) => {
resolve({ code: error?.code ?? 0, stdout, stderr });
}
);
});
const temporaryDirectory = async () => {
const directory = await mkdtemp(path.join(tmpdir(), "ha-build-cli-test-"));
temporaryDirectories.push(directory);
return directory;
};
const writeOwner = async (owner) => {
const cache = await temporaryDirectory();
const lockFile = path.join(cache, "ha-workflow.lock");
const record = {
pid: process.pid,
startTime: processStartTime(process.pid),
...owner,
};
await writeFile(lockFile, JSON.stringify(record));
return { cache, lockFile, record };
};
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
);
});
describe("build management CLIs", () => {
it("blocks a managed workflow when another owns the output", async () => {
const { cache } = await writeOwner({ kind: "build", token: "build" });
const result = await runNode(
["build-scripts/dev-server.mjs", "--suite", "app", "--background"],
{ HA_BUILD_CACHE_DIR: cache }
);
expect(result.code).toBe(1);
expect(result.stdout).toContain("Frontend build already running");
});
it("does not stop another development suite", async () => {
const { cache, lockFile, record } = await writeOwner({
kind: "dev",
suite: "demo",
token: "demo",
});
const result = await runNode(
["build-scripts/dev-server.mjs", "--suite", "gallery", "--stop"],
{ HA_BUILD_CACHE_DIR: cache }
);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Dev server (gallery) not running");
expect(JSON.parse(await readFile(lockFile, "utf8"))).toEqual(record);
});
it("keeps an exact process suite start idempotent", async () => {
const { cache } = await writeOwner({
kind: "dev",
suite: "app",
token: "app",
});
const result = await runNode(
["build-scripts/dev-server.mjs", "--suite", "app", "--background"],
{ HA_BUILD_CACHE_DIR: cache }
);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Dev server (app) already running");
});
});
describe("workflow ownership", () => {
it("removes owned locks when the child is terminated", async () => {
const cache = await temporaryDirectory();
const lockFile = path.join(cache, "ha-workflow.lock");
const script = [
'import { createWorkflowLockTask } from "./build-scripts/output-lock.mjs";',
'await createWorkflowLockTask("test")();',
'process.stdout.write("ready\\n");',
"setInterval(() => {}, 10000);",
].join("");
const child = spawn(
process.execPath,
["--input-type=module", "-e", script],
{
cwd: repoRoot,
env: { ...process.env, HA_BUILD_CACHE_DIR: cache },
stdio: ["ignore", "pipe", "inherit"],
}
);
await new Promise((resolve, reject) => {
child.stdout.once("data", resolve);
child.once("error", reject);
});
child.kill("SIGTERM");
await new Promise((resolve) => {
child.once("exit", resolve);
});
await expect(readFile(lockFile)).rejects.toMatchObject({ code: "ENOENT" });
});
});
@@ -0,0 +1,64 @@
/**
* @vitest-environment node
*/
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
import { afterEach, describe, expect, it } from "vitest";
import {
acquireProcessRecord,
processStartTime,
readProcessRecord,
releaseProcessRecord,
} from "../../build-scripts/managed-process.mjs";
const temporaryDirectories = [];
const temporaryFile = async (name) => {
const directory = await mkdtemp(path.join(tmpdir(), "ha-build-test-"));
temporaryDirectories.push(directory);
return path.join(directory, name);
};
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
);
});
describe("managed process records", () => {
it("recovers a stale owner", async () => {
const file = await temporaryFile("stale.lock");
await writeFile(
file,
JSON.stringify({ pid: 2147483647, startTime: "stale", token: "stale" })
);
const owner = {
pid: process.pid,
startTime: processStartTime(process.pid),
token: "current",
};
expect(acquireProcessRecord(file, owner).acquired).toBe(true);
expect(readProcessRecord(file)).toEqual(owner);
});
it("releases only for the owning token", async () => {
const file = await temporaryFile("token.lock");
const owner = {
pid: process.pid,
startTime: processStartTime(process.pid),
token: "owner",
};
acquireProcessRecord(file, owner);
releaseProcessRecord(file, "other");
expect(readProcessRecord(file)).toEqual(owner);
releaseProcessRecord(file, owner.token);
expect(readProcessRecord(file)).toBeUndefined();
});
});
+7 -1
View File
@@ -1,5 +1,11 @@
global.window = (global.window ?? {}) as any;
global.navigator = (global.navigator ?? {}) as any;
if (!global.navigator) {
Object.defineProperty(global, "navigator", {
value: {},
configurable: true,
writable: true,
});
}
global.__DEMO__ = false;
global.__DEV__ = false;