fix(runtime): verify Windows port ownership

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-16 19:23:16 +00:00
1 parent a88c241b55
commit b8637fb3f4
3 files changed
+84 -18

No files matched your search

@@ -49,6 +49,7 @@ import {
import {
findAdoptableLocalService,
isLocalServiceRegistryCwdCompatible,
isLocalServiceProcessOwnedBy,
isLocalServiceProcessInWorkspace,
readLocalServicePortOwner,
writeLocalServiceRegistryRecord,
@@ -4718,6 +4719,35 @@ describe("readLocalServicePortOwner", () => {
}
});
it("attributes a Windows listener to a descendant of the launched process", async () => {
const fakeBin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-windows-tools-"));
const previousPath = process.env.PATH;
const port = 43_123;
const listenerPid = 43_210;
const launchedPid = 32_000;
await fs.writeFile(
path.join(fakeBin, "netstat"),
`#!/bin/sh\nprintf ' TCP 127.0.0.1:${port} 0.0.0.0:0 LISTENING ${listenerPid}\\n'\n`,
{ mode: 0o755 },
);
await fs.writeFile(
path.join(fakeBin, "powershell.exe"),
`#!/bin/sh\nprintf '${launchedPid}\\n'\n`,
{ mode: 0o755 },
);
process.env.PATH = `${fakeBin}${path.delimiter}${previousPath ?? ""}`;
Object.defineProperty(process, "platform", { value: "win32" });
try {
await expect(readLocalServicePortOwner(port)).resolves.toBe(listenerPid);
await expect(isLocalServiceProcessOwnedBy(listenerPid, launchedPid)).resolves.toBe(true);
} finally {
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
await fs.rm(fakeBin, { recursive: true, force: true });
}
});
it("accepts service cwd nested within the requested workspace", async () => {
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-"));
const serviceCwd = path.join(workspace, "server");
@@ -296,6 +296,39 @@ export async function readLocalServiceProcessGroupId(pid: number) {
}
}
export async function isLocalServiceProcessOwnedBy(pid: number, ownerProcessId: number) {
if (pid === ownerProcessId) return true;
if (process.platform !== "win32") {
return (await readLocalServiceProcessGroupId(pid)) === ownerProcessId;
}
try {
const script = [
`$currentProcessId = ${pid}`,
"while ($currentProcessId -gt 0) {",
" $process = Get-CimInstance Win32_Process -Filter \"ProcessId = $currentProcessId\" -ErrorAction SilentlyContinue",
" if ($null -eq $process) { break }",
" $parentProcessId = [int]$process.ParentProcessId",
" Write-Output $parentProcessId",
" if ($parentProcessId -eq $currentProcessId) { break }",
" $currentProcessId = $parentProcessId",
"}",
].join("\n");
const { stdout } = await execFileAsync("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
script,
]);
return stdout
.split(/\r?\n/)
.map((line) => Number.parseInt(line.trim(), 10))
.some((ancestorPid) => ancestorPid === ownerProcessId);
} catch {
return false;
}
}
async function adoptLocalServiceFromPortOwner(input: {
serviceKey: string;
profileKind?: string | null;
@@ -405,8 +438,24 @@ export async function terminateLocalService(
}
export async function readLocalServicePortOwner(port: number) {
if (!Number.isInteger(port) || port <= 0 || process.platform === "win32") return null;
if (!Number.isInteger(port) || port <= 0) return null;
try {
if (process.platform === "win32") {
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"]);
for (const line of stdout.split(/\r?\n/)) {
const columns = line.trim().split(/\s+/);
if (columns.length < 5 || columns[0]?.toUpperCase() !== "TCP") continue;
const localAddress = columns[1] ?? "";
const separatorIndex = localAddress.lastIndexOf(":");
const localPort = Number.parseInt(localAddress.slice(separatorIndex + 1), 10);
const state = columns.at(-2)?.toUpperCase();
const pid = Number.parseInt(columns.at(-1) ?? "", 10);
if (localPort === port && state === "LISTENING" && Number.isInteger(pid) && pid > 0) {
return pid;
}
}
return null;
}
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
const firstPid = stdout
.split("\n")
+4 -17
View File
@@ -28,9 +28,9 @@ import {
createLocalServiceKey,
findLocalServiceRegistryRecordByRuntimeServiceId,
findAdoptableLocalService,
isLocalServiceProcessOwnedBy,
isLocalServiceProcessInWorkspace,
readLocalServiceProcessCwd,
readLocalServiceProcessGroupId,
readLocalServicePortOwner,
removeLocalServiceRegistryRecord,
terminateLocalService,
@@ -4008,10 +4008,7 @@ async function waitForAllocatedPortBind(input: {
const ownerPid = await readLocalServicePortOwner(input.port);
if (ownerPid) {
const childPid = input.child.pid ?? null;
const ownerProcessGroupId = ownerPid === childPid
? childPid
: await readLocalServiceProcessGroupId(ownerPid);
if (!childPid || ownerProcessGroupId !== childPid) {
if (!childPid || !(await isLocalServiceProcessOwnedBy(ownerPid, childPid))) {
throw new RuntimeServicePortBindCollision(input.port);
}
// Require the same launched process group to retain ownership across a stability delay.
@@ -4021,12 +4018,7 @@ async function waitForAllocatedPortBind(input: {
throw new Error("service process exited after losing its allocated port");
}
const stableOwnerPid = await readLocalServicePortOwner(input.port);
const stableOwnerProcessGroupId = stableOwnerPid === childPid
? childPid
: stableOwnerPid
? await readLocalServiceProcessGroupId(stableOwnerPid)
: null;
if (stableOwnerProcessGroupId !== childPid) {
if (!stableOwnerPid || !(await isLocalServiceProcessOwnedBy(stableOwnerPid, childPid))) {
throw new RuntimeServicePortBindCollision(input.port);
}
return;
@@ -4041,12 +4033,7 @@ async function waitForAllocatedPortBind(input: {
}
const stableOwnerPid = await readLocalServicePortOwner(input.port);
const childPid = input.child.pid ?? null;
const stableOwnerProcessGroupId = stableOwnerPid === childPid
? childPid
: stableOwnerPid
? await readLocalServiceProcessGroupId(stableOwnerPid)
: null;
if (childPid && stableOwnerProcessGroupId === childPid) return;
if (stableOwnerPid && childPid && await isLocalServiceProcessOwnedBy(stableOwnerPid, childPid)) return;
throw new RuntimeServicePortBindCollision(input.port);
}
await delay(50);