fix: harden dev stack process detection
This commit is contained in:
@@ -181,6 +181,61 @@ function isPidRunning(pid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readProcessCommand(pid) {
|
||||||
|
if (!pid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = result.stdout.trim();
|
||||||
|
return command.length > 0 ? command : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServiceCommandMatchers(service) {
|
||||||
|
switch (service.key) {
|
||||||
|
case "temporal":
|
||||||
|
return ["temporal", "server", "start-dev"];
|
||||||
|
case "backend-api":
|
||||||
|
return ["uvicorn", "app.main:app"];
|
||||||
|
case "backend-worker":
|
||||||
|
return ["app.workers.runner"];
|
||||||
|
case "frontend":
|
||||||
|
return ["next", "dev", "127.0.0.1", "3000"];
|
||||||
|
default:
|
||||||
|
return service.command.map((part) => path.basename(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isServiceProcessMatch(service, processCommand) {
|
||||||
|
if (!processCommand) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCommand = processCommand.toLowerCase();
|
||||||
|
return getServiceCommandMatchers(service).every((matcher) =>
|
||||||
|
normalizedCommand.includes(matcher.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isServiceHealthy(service, { pid, processCommand, portOpen }) {
|
||||||
|
if (!pid || !isServiceProcessMatch(service, processCommand)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service.port) {
|
||||||
|
return portOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function removePidFile(pidFilePath) {
|
function removePidFile(pidFilePath) {
|
||||||
if (fs.existsSync(pidFilePath)) {
|
if (fs.existsSync(pidFilePath)) {
|
||||||
fs.unlinkSync(pidFilePath);
|
fs.unlinkSync(pidFilePath);
|
||||||
@@ -309,6 +364,12 @@ async function stopService(config, service) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const processCommand = readProcessCommand(pid);
|
||||||
|
if (!isServiceProcessMatch(service, processCommand)) {
|
||||||
|
removePidFile(pidFilePath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
process.kill(-pid, "SIGTERM");
|
process.kill(-pid, "SIGTERM");
|
||||||
for (let index = 0; index < 20; index += 1) {
|
for (let index = 0; index < 20; index += 1) {
|
||||||
if (!isPidRunning(pid)) {
|
if (!isPidRunning(pid)) {
|
||||||
@@ -430,10 +491,21 @@ async function printStatus() {
|
|||||||
const pidFilePath = getServicePidFile(config, service.key);
|
const pidFilePath = getServicePidFile(config, service.key);
|
||||||
const logFilePath = getServiceLogFile(config, service.key);
|
const logFilePath = getServiceLogFile(config, service.key);
|
||||||
const pid = readPid(pidFilePath);
|
const pid = readPid(pidFilePath);
|
||||||
const running = pid ? isPidRunning(pid) : false;
|
const processCommand =
|
||||||
|
pid && isPidRunning(pid) ? readProcessCommand(pid) : null;
|
||||||
|
const portOpen = service.port ? await isLocalPortBusy(service.port) : false;
|
||||||
|
const running = isServiceHealthy(service, {
|
||||||
|
pid,
|
||||||
|
processCommand,
|
||||||
|
portOpen,
|
||||||
|
});
|
||||||
|
if (!running && pid) {
|
||||||
|
removePidFile(pidFilePath);
|
||||||
|
}
|
||||||
|
const displayPid = running ? pid : null;
|
||||||
const portText = service.port ? ` port=${service.port}` : "";
|
const portText = service.port ? ` port=${service.port}` : "";
|
||||||
const logText = fs.existsSync(logFilePath) ? ` log=${logFilePath}` : "";
|
const logText = fs.existsSync(logFilePath) ? ` log=${logFilePath}` : "";
|
||||||
console.log(`${service.key}: ${running ? "running" : "stopped"} pid=${pid ?? "-"}${portText}${logText}`);
|
console.log(`${service.key}: ${running ? "running" : "stopped"} pid=${displayPid ?? "-"}${portText}${logText}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import {
|
|||||||
createStackConfig,
|
createStackConfig,
|
||||||
formatServiceLogs,
|
formatServiceLogs,
|
||||||
getDefaultTemporalCandidates,
|
getDefaultTemporalCandidates,
|
||||||
|
getServiceCommandMatchers,
|
||||||
|
isServiceHealthy,
|
||||||
|
isServiceProcessMatch,
|
||||||
resolveTemporalCli,
|
resolveTemporalCli,
|
||||||
selectServicesForLogs,
|
selectServicesForLogs,
|
||||||
} from "../../scripts/dev-stack/stack.mjs";
|
} from "../../scripts/dev-stack/stack.mjs";
|
||||||
|
|
||||||
|
type StackService = ReturnType<typeof createStackConfig>["services"][number];
|
||||||
|
|
||||||
describe("createStackConfig", () => {
|
describe("createStackConfig", () => {
|
||||||
it("derives frontend and sibling backend paths from the workspace root", () => {
|
it("derives frontend and sibling backend paths from the workspace root", () => {
|
||||||
const config = createStackConfig("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
const config = createStackConfig("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
||||||
@@ -181,3 +186,50 @@ describe("formatServiceLogs", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("service process matching", () => {
|
||||||
|
it("does not treat an unrelated process with a reused pid as the frontend service", () => {
|
||||||
|
const config = createStackConfig("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
||||||
|
const frontend = config.services.find((service: StackService) => service.key === "frontend");
|
||||||
|
|
||||||
|
expect(frontend).toBeDefined();
|
||||||
|
expect(
|
||||||
|
isServiceProcessMatch(frontend!, "node /tmp/unrelated-script.js --watch"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds stable command matchers for backend api and worker processes", () => {
|
||||||
|
const config = createStackConfig("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
||||||
|
const backendApi = config.services.find((service: StackService) => service.key === "backend-api");
|
||||||
|
const backendWorker = config.services.find((service: StackService) => service.key === "backend-worker");
|
||||||
|
|
||||||
|
expect(getServiceCommandMatchers(backendApi!)).toEqual(["uvicorn", "app.main:app"]);
|
||||||
|
expect(getServiceCommandMatchers(backendWorker!)).toEqual(["app.workers.runner"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the expected port to be open for port-bound services", () => {
|
||||||
|
const config = createStackConfig("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
||||||
|
const backendApi = config.services.find((service: StackService) => service.key === "backend-api");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isServiceHealthy(backendApi!, {
|
||||||
|
pid: 123,
|
||||||
|
processCommand: "/path/to/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000",
|
||||||
|
portOpen: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows worker services without ports when the process command matches", () => {
|
||||||
|
const config = createStackConfig("/Volumes/DockCase/codes/auto-virtual-tryon-frontend");
|
||||||
|
const backendWorker = config.services.find((service: StackService) => service.key === "backend-worker");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isServiceHealthy(backendWorker!, {
|
||||||
|
pid: 123,
|
||||||
|
processCommand: "/path/to/python -m app.workers.runner",
|
||||||
|
portOpen: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user