From 40bf9de8d7c337a5cf4e7698dbecb1d0aaf3944d Mon Sep 17 00:00:00 2001 From: afei A <57030625+NewHubBoy@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:57:47 +0800 Subject: [PATCH] fix: harden dev stack process detection --- scripts/dev-stack/stack.mjs | 76 ++++++++++++++++++++++++++++++++- tests/scripts/dev-stack.test.ts | 52 ++++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/scripts/dev-stack/stack.mjs b/scripts/dev-stack/stack.mjs index 3e3d5da..da651b4 100644 --- a/scripts/dev-stack/stack.mjs +++ b/scripts/dev-stack/stack.mjs @@ -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) { if (fs.existsSync(pidFilePath)) { fs.unlinkSync(pidFilePath); @@ -309,6 +364,12 @@ async function stopService(config, service) { return false; } + const processCommand = readProcessCommand(pid); + if (!isServiceProcessMatch(service, processCommand)) { + removePidFile(pidFilePath); + return false; + } + process.kill(-pid, "SIGTERM"); for (let index = 0; index < 20; index += 1) { if (!isPidRunning(pid)) { @@ -430,10 +491,21 @@ async function printStatus() { const pidFilePath = getServicePidFile(config, service.key); const logFilePath = getServiceLogFile(config, service.key); 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 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}`); } } diff --git a/tests/scripts/dev-stack.test.ts b/tests/scripts/dev-stack.test.ts index 0c164f8..8873b0e 100644 --- a/tests/scripts/dev-stack.test.ts +++ b/tests/scripts/dev-stack.test.ts @@ -4,10 +4,15 @@ import { createStackConfig, formatServiceLogs, getDefaultTemporalCandidates, + getServiceCommandMatchers, + isServiceHealthy, + isServiceProcessMatch, resolveTemporalCli, selectServicesForLogs, } from "../../scripts/dev-stack/stack.mjs"; +type StackService = ReturnType["services"][number]; + describe("createStackConfig", () => { it("derives frontend and sibling backend paths from the workspace root", () => { 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); + }); +});