UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

855 lines • 42.4 kB
// Port → owning PID lookup. // // Extracted from process-control so the live-interpreter resolver can use it // WITHOUT importing process-control (which imports workspace-env, which consumes // the resolver — that would be an import cycle). This module is a LEAF: node // builtins only. process-control re-exports parseListenerPidFromNetstat so its // existing tests keep importing it from there. import { execSync } from "node:child_process"; import { readFileSync, readdirSync, readlinkSync } from "node:fs"; import { platform } from "node:os"; const IS_WIN = platform() === "win32"; /** * Extract the PID that is LISTENING on `port` from `netstat -ano` output. * * We anchor on the local-address `:PORT` suffix rather than grepping the state * word ("LISTENING"), which is TRANSLATED on non-English Windows (German * "ABHÖREN", French "À L'ÉCOUTE", …). The old `findstr LISTENING` filter dropped * every line on those systems, so a perfectly reachable ComfyUI looked like "no * process on port". Anchoring on the local column also correctly IGNORES an * outbound/established connection whose REMOTE peer happens to use `:PORT`. * * Exported for tests: this pure function is where the bug lived. */ /** Loopback spellings that all denote "this machine". */ const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]); /** Bind addresses that accept connections for EVERY local address. */ const WILDCARD = new Set(["0.0.0.0", "::", "*", ""]); /** Split "127.0.0.1:8188" / "[::1]:8188" / "*:8188" into address + port. */ function splitEndpoint(endpoint) { const idx = endpoint.lastIndexOf(":"); if (idx < 0) return { address: endpoint, port: "" }; const address = endpoint.slice(0, idx).replace(/^\[|\]$/g, ""); return { address, port: endpoint.slice(idx + 1) }; } /** * Does a listener bound to `address` serve the host we are actually talking to? * * A wildcard bind serves every local address, so it always matches. Otherwise the * addresses must agree, with the loopback spellings treated as equivalent. Binding * the lookup to the HOST — not just the port number — keeps two instances on * different loopback addresses from being confused for one another (#401 round 4). */ export function addressServesHost(address, wantHost) { const a = address.toLowerCase(); if (WILDCARD.has(a)) return true; if (!wantHost) return true; const w = wantHost.toLowerCase().replace(/^\[|\]$/g, ""); if (a === w) return true; return LOOPBACK.has(a) && LOOPBACK.has(w); } export function parseListenerPidFromNetstat(output, port, host) { const suffix = `:${port}`; for (const raw of output.split(/\r?\n/)) { const line = raw.trim(); if (!line) continue; const parts = line.split(/\s+/); // Expected columns: PROTO LOCAL FOREIGN STATE PID if (parts.length < 5) continue; if (parts[0].toUpperCase() !== "TCP") continue; const local = parts[1]; const foreign = parts[2]; // The leading colon anchors the match: "…:8188" matches but "…:81880" and // "…:18188" do not (their last chars aren't ":8188"). if (!local.endsWith(suffix)) continue; // Require a LISTENING row without depending on the localized state word: a // listener's foreign endpoint is always the wildcard port 0 (0.0.0.0:0 / // [::]:0). This rejects an ESTABLISHED connection that merely bound its // local side to :PORT — killing that would take down the wrong process // (or none) when ComfyUI is actually down. if (!foreign.endsWith(":0")) continue; if (!addressServesHost(splitEndpoint(local).address, host)) continue; const pid = parseInt(parts[parts.length - 1], 10); if (!Number.isNaN(pid) && pid > 0) return pid; } return null; } /** * Parse `lsof -V -nP -iTCP:PORT -sTCP:LISTEN -Fpn` field output. Records look like * p1234 * n127.0.0.1:8188 * so we can bind the match to the ADDRESS as well as the port, which the plain * `-t` (terse) output cannot express. */ export function parseListenerPidFromLsof(output, port, host) { let pid = null; for (const raw of output.split(/\r?\n/)) { const line = raw.trim(); if (!line) continue; if (line.startsWith("p")) { const parsed = parseInt(line.slice(1), 10); pid = !Number.isNaN(parsed) && parsed > 0 ? parsed : null; continue; } if (line.startsWith("n") && pid !== null) { const endpoint = line.slice(1); // Ignore a peer-qualified name ("a->b"); listeners have no peer. if (endpoint.includes("->")) continue; const { address, port: p } = splitEndpoint(endpoint); if (p !== String(port)) continue; if (!addressServesHost(address, host)) continue; return pid; } } return null; } /** * Did `lsof` positively STATE that nothing is listening? * * Its exit status cannot answer this. Verified against the manual and by * experiment: lsof "returns a one (1) if any error was detected, INCLUDING the * failure to locate ... Internet addresses", so status 1 conflates "searched and * found nothing" with "could not search". Worse, a run that enumerates nothing * because it lacks permission also produces status 1 with EMPTY stdout and EMPTY * stderr — so "quiet" is not evidence either, with or without `-w` (which only * suppresses warnings and therefore makes silence less meaningful, not more). * * What IS a positive statement of absence is `-V`, which makes lsof say what it * failed to locate: `lsof: Internet address not located: TCP:8188`. That marker, * and nothing else, is treated as "free". Anything else exits to "unknown", which * a caller may not spend as proof of release. */ /** * lsof reporting that its own enumeration was INCOMPLETE. * * Any such note voids the absence marker: "I searched and did not find it" is a * statement about the whole machine, but a run that could not stat or open part of * `/proc` only searched the part it could see. The marker and a warning can appear * TOGETHER — lsof reports what it skipped and still names what it failed to locate — * and reading that combination as "free" certifies a release from an enumeration * that admits it was partial. */ /** * Did lsof complain about anything, i.e. admit its enumeration was incomplete? * * Recognised by INVERSION rather than by enumerating diagnostics. Listing the ones I * could think of (`WARNING`, `permission denied`, …) missed real ones lsof documents * — `lsof: can't read proc table info` among them — and every miss certifies a * release from a partial search. The set of things lsof can complain about is open; * the set of things it says as ITS `-V` REPORT is closed and known: `not located` * lines, verified from a live run to be exactly * * lsof: Internet address not located: TCP:65432 * lsof: TCP state not located: LISTEN * * So any `lsof:`-prefixed line that is NOT part of that report is a complaint, * whatever it says. `Output information may be incomplete.` is checked separately * because lsof prints it without the prefix. */ function admitsIncompleteEnumeration(output) { if (/may be incomplete/i.test(output)) return true; return output.split("\n").some((raw) => { const line = raw.trim(); if (!/^lsof:/i.test(line)) return false; return !/\bnot located\b/i.test(line); }); } const LSOF_INCONCLUSIVE_REASON = "lsof reported nothing listening on the port but also reported that it could not enumerate everything, so its search describes only what it could see"; /** * THE ONLY WAY THIS MODULE MAY RUN `lsof`. * * Invariant 4 — lsof must STATE absence (`-V`); silence is not evidence — is a * property of the COMMAND, and a rule that lives in a comment survives only as long * as everyone remembers it at the next call site. A quiet `lsof` without `-V` exits 1 * with empty stdout AND stderr whether it searched and found nothing or could not * search at all, so a second invocation that omitted the flag would hand a caller a * `free` verdict for a live port. Building the argument list HERE, once, is what * makes the invariant structural: there is no other place to get an lsof result from, * and a future call site inherits `-V` whether or not its author knew to ask for it. * * `2>&1` for the matching reason: lsof reports an INCOMPLETE enumeration on stderr, * and on the success path `execSync` returns stdout ONLY — so without the redirect a * marker on stdout beside a warning on stderr reads as a clean absence and certifies * a release from a search that admitted it was partial. The error path already sees * both streams; this makes the success path see what the error path sees. Warnings do * not disturb the field parser, which keys on `p`/`n` line prefixes, so a host whose * lsof warns routinely loses only the fast path — a wait, never a wrong answer. */ function runLsofListenerQuery(port) { return execSync(`lsof -V -nP -iTCP:${port} -sTCP:LISTEN -Fpn 2>&1`, { encoding: "utf-8", timeout: 5000, }); } function readLsofAbsence(output) { if (!/internet address not located/i.test(output)) return "silent"; return admitsIncompleteEnumeration(output) ? "inconclusive" : "stated"; } function lsofErrorAbsence(err) { const e = err; // A spawn-level failure (command missing, timeout) tells us nothing at all. if (e?.code) return "silent"; if (e?.status !== 1) return "silent"; const text = (v) => v == null ? "" : typeof v === "string" ? v : v.toString("utf-8"); return readLsofAbsence(`${text(e.stdout)}\n${text(e.stderr)}`); } const defaultProcNetReader = (table) => readFileSync(table, "utf-8"); let procNetReader = defaultProcNetReader; const defaultProcFdReader = { listPids: () => readdirSync("/proc") .filter((name) => /^\d+$/.test(name)) .map((name) => Number(name)), listFds: (pid) => readdirSync(`/proc/${pid}/fd`), readFdLink: (pid, fd) => readlinkSync(`/proc/${pid}/fd/${fd}`), }; let procFdReader = defaultProcFdReader; export const __portOwnerTestHooks = { /** Drive the kernel-table read. `null` restores the real `/proc` reader. */ setProcNetReader(fn) { procNetReader = fn ?? defaultProcNetReader; }, /** Drive the `/proc/<pid>/fd` walk (#795). `null` restores the real reader. */ setProcFdReader(fn) { procFdReader = fn ?? defaultProcFdReader; }, reset() { procNetReader = defaultProcNetReader; procFdReader = defaultProcFdReader; }, }; /** * Does a failed table read mean the table is ABSENT, or that we could not look? * * Classified from the errno, exactly as this change classifies a launcher config, * because "both reads failed" is not by itself evidence of an absent source. * * Only ENOENT and ENOTDIR prove absence: they say this PATHNAME does not resolve to * a file, so there is no table and nothing can be hiding in it. Every non-Linux * POSIX host has no `/proc/net` at all, and a kernel booted with `ipv6.disable=1` * has no `tcp6`. * * Everything else is "I could not look": a blind spot an unprivileged lsof may * SHARE, which must never be spent as proof the port is free. That deliberately * includes ENOSYS ("function not implemented") and ENXIO ("no such device or * address") — both describe a failed OPERATION, not a pathname that resolves to * nothing, so neither rules out a listener in a table we did not read. An * errno-less failure is likewise a blind spot. */ function tableIsAbsent(err) { const code = err?.code; return code === "ENOENT" || code === "ENOTDIR"; } /** * A kernel socket row: `<hex-address>:<hex-port>` (8 hex digits for IPv4, 32 for * IPv6) and a two-hex-digit state. Verified against real kernel output — * `0100007F:1FFC` / `00000000000000000000000001000000:49D3`, state `0A`. Requiring * the SHAPE, not merely four fields, is what stops a garbled row from being * silently skipped and then reported as "no listener here". */ const KERNEL_LOCAL_ENDPOINT = /^([0-9A-Fa-f]{8}|[0-9A-Fa-f]{32}):([0-9A-Fa-f]{4})$/; const KERNEL_STATE = /^[0-9A-Fa-f]{2}$/; const KERNEL_SLOT = /^(\d+):$/; /** The kernel's hex code for TCP_LISTEN. */ const LISTEN_STATE = "0A"; /** * The highest TCP state these tables can print: `TCP_CLOSING` (11 = 0x0B). * * The enum defines two values above it, and NEITHER reaches this column: * * `TCP_NEW_SYN_RECV` (12 = 0x0C) is the sk_state of a request socket, but the * seq_show path renders those through `get_openreq4`, which prints the state as * `TCP_SYN_RECV` (3). So a half-open connection appears here as `03`, never `0C`. * `TCP_BOUND_INACTIVE` (13 = 0x0D) is a pseudo-state for inet_diag, which walks the * BIND table — and these files do not. `/proc/net/tcp` iterates the listening and * established hashes only. MEASURED on 6.18: a socket bound to a port without * `listen()` produces NO row here at all, in any state. * * So the domain is 01..0B, and anything above it is damage rather than a socket. A * future kernel printing a new state costs that host only its fast path * (`incomplete`, i.e. `unknown`), never a false answer. */ const MAX_TCP_STATE = 0x0b; /** * The socket tables, each with the address width it is DEFINED to print: one 32-bit * word for IPv4, four for IPv6. Binding rows to their table is what stops a 32-hex * row in `/proc/net/tcp` — impossible, and therefore evidence of nothing — from * being read as either a listener or a clean negative. */ const KERNEL_TABLES = [ { path: "/proc/net/tcp", addressHexWidth: 8, header: kernelHeader("rem_address") }, { path: "/proc/net/tcp6", addressHexWidth: 32, header: kernelHeader("remote_address"), }, ]; const KERNEL_QUEUES = /^[0-9A-Fa-f]{8}:[0-9A-Fa-f]{8}$/; const KERNEL_TIMER = /^[0-9A-Fa-f]{2}:[0-9A-Fa-f]{8}$/; const KERNEL_HEX8 = /^[0-9A-Fa-f]{8}$/; const KERNEL_DECIMAL = /^\d+$/; /** * The first four columns — slot, local endpoint, remote endpoint, state — which are * the MINIMUM that can carry a positive finding. * * A positive is read from this prefix rather than from a whole row, because * truncation after the state cannot unmake the fields before it. But it must be the * whole prefix: `cols[1]` and `cols[3]` alone would let a line like * `corrupt 0100007F:1FFC garbage 0A` fabricate a listener out of bytes we have * every reason to distrust. Untrustworthy input must not become a definite finding * in EITHER direction. */ function parseKernelRowPrefix(cols, addressHexWidth) { const slot = KERNEL_SLOT.exec(cols[0] ?? ""); const local = KERNEL_LOCAL_ENDPOINT.exec(cols[1] ?? ""); const remote = KERNEL_LOCAL_ENDPOINT.exec(cols[2] ?? ""); const state = cols[3] ?? ""; if (!slot || !local || !remote || !KERNEL_STATE.test(state)) return undefined; // CROSS-FIELD consistency, because four individually well-shaped tokens can still // be a row no kernel ever wrote. Both endpoints must be the width THIS TABLE is // defined to print — which also settles the two against each other. if (local[1].length !== addressHexWidth || remote[1].length !== addressHexWidth) { return undefined; } // The state must be one the kernel can actually be in. const stateCode = parseInt(state, 16); if (stateCode < 1 || stateCode > MAX_TCP_STATE) return undefined; // A LISTENING socket has no peer: its remote endpoint is all zeros on port 0. The // netstat parser above leans on the same invariant (#401) to tell a listener from // an established connection that merely bound its local side to the port. if (state.toUpperCase() === LISTEN_STATE && (remote[2] !== "0000" || /[^0]/.test(remote[1]))) { return undefined; } return { slot: Number(slot[1]), localPort: local[2], localAddressHex: local[1], state, }; } function parseKernelRow(cols, addressHexWidth) { if (cols.length < 10) return undefined; const prefix = parseKernelRowPrefix(cols, addressHexWidth); if (!prefix || !KERNEL_QUEUES.test(cols[4]) || !KERNEL_TIMER.test(cols[5]) || !KERNEL_HEX8.test(cols[6]) || !KERNEL_DECIMAL.test(cols[7]) || !KERNEL_DECIMAL.test(cols[8]) || !KERNEL_DECIMAL.test(cols[9])) { return undefined; } return { ...prefix, inode: cols[9] }; } /** * The kernel's hex local address as text the ADDRESS COMPARATOR understands, or * `undefined` when this module will not risk a comparison. * * Both tables print addresses as little-endian 32-bit words, so the bytes come out * reversed WITHIN each word: `0100007F` is 127.0.0.1, and the 32-digit * `00000000000000000000000001000000` is `::1`. Verified against live kernel output. * * Only the forms whose spelling is unambiguous are produced — a dotted quad, the * two wildcards, `::1`, and an IPv4-mapped `::ffff:a.b.c.d` reduced to its IPv4 * form. A general IPv6 address has several legal textual spellings (which groups * `::` elides, leading zeros, case), and picking one to string-compare against a * user-supplied host would be a guess; guessing here would produce a POSITIVE * attribution, which is the direction that licenses stopping a process. So it * returns `undefined` and the caller declines to attribute — the honest cost is a * fall-through to lsof, never a wrong owner. */ function decodeKernelAddress(hex) { const bytes = []; if (hex.length !== 8 && hex.length !== 32) return undefined; for (let word = 0; word < hex.length / 8; word++) { const w = hex.slice(word * 8, word * 8 + 8); // Little-endian: the LAST byte pair of the word is the FIRST address byte. for (let i = 3; i >= 0; i--) { const byte = parseInt(w.slice(i * 2, i * 2 + 2), 16); if (Number.isNaN(byte)) return undefined; bytes.push(byte); } } if (bytes.length === 4) return bytes.join("."); const zeros = (from, to) => bytes.slice(from, to).every((b) => b === 0); if (zeros(0, 16)) return "::"; if (zeros(0, 15) && bytes[15] === 1) return "::1"; // IPv4-mapped (::ffff:a.b.c.d) — what a dual-stack socket shows for an IPv4 peer. if (zeros(0, 10) && bytes[10] === 0xff && bytes[11] === 0xff) { return bytes.slice(12).join("."); } return undefined; } /** * May a kernel row be attributed to the host we are actually talking to? * * With no host constraint every listener on the port qualifies, exactly as the lsof * parser behaves — so the address is not even decoded. With one, an address we * decline to decode is NOT a match: the strictness belongs to the finding that * would name an owner. */ function kernelRowServesHost(localAddressHex, wantHost) { if (!wantHost) return true; const decoded = decodeKernelAddress(localAddressHex); if (decoded === undefined) return false; return addressServesHost(decoded, wantHost); } /** * The COMPLETE column header, matched end to end — EVERY column named in order. * * Anchoring only the start would accept a header truncated to `sl local_address`, * and a body truncated to nothing then reads as a table with no listeners — "the * header parsed" silently standing in for "the body was read". Nor is it enough to * pin only the two ends and allow anything between: `sl local_address rem_address * inode` would satisfy that while missing every intervening column, which is the * same truncation one level in. So the whole sequence is spelled out. * * Captured from a live kernel: IPv4 says `rem_address`, IPv6 says `remote_address`, * and the IPv4 line carries trailing padding (removed by the caller's trim). Each * table gets its OWN pattern rather than accepting either spelling, because the two * are not interchangeable — an IPv4-form header standing over `/proc/net/tcp6` is a * table we did not really read, not a table with nothing in it. * * A layout this does not recognise costs that host only its fast path — the read * becomes `incomplete`, which is the safe direction — never a false negative. */ function kernelHeader(remoteColumn) { return new RegExp(`^sl\\s+local_address\\s+${remoteColumn}\\s+st\\s+tx_queue\\s+rx_queue\\s+tr\\s+tm->when\\s+retrnsmt\\s+uid\\s+timeout\\s+inode$`, "i"); } function readKernelSocketTables(port, host) { const wanted = port.toString(16).toUpperCase().padStart(4, "0"); const inodes = []; let sawListener = false; let tablesRead = 0; let blind = false; for (const { path: table, addressHexWidth, header } of KERNEL_TABLES) { let raw; try { raw = procNetReader(table); } catch (err) { // An absent table hides nothing; an unreadable one hides anything. if (!tableIsAbsent(err)) blind = true; continue; } tablesRead++; // Row COUNT cannot distinguish a genuinely idle table from one truncated after // its header — both have zero rows. So the table's OWN BOUNDARIES do: a real // /proc/net/tcp always opens with the column header and always ends with a // newline. Seeing neither end is what separates "I read the whole table and it // held no listener" from "I read part of a table". // // A POSITIVE is unaffected and returns immediately below: a socket we can see is // a socket, however much else was lost. Only the NEGATIVE needs completeness. let sawHeader = false; let nextSlot = 0; const lines = raw.split("\n"); for (let i = 0; i < lines.length; i++) { const text = lines[i].trim(); // The header must OPEN the table — its first physical line. Accepting it // wherever it turns up would let a table whose beginning was lost, or one with // rows standing before it, pass as a table we watched from the start. if (i === 0 && header.test(text)) { sawHeader = true; continue; } if (text === "") { // Only the FINAL element may be blank — it is the artifact of the table's // terminating newline. A blank ANYWHERE else is an empty record, which the // kernel never writes: one non-empty line per entry. Treating an interior // gap as ordinary structure would let a record deleted from the middle pass // as a table that simply had nothing there. if (i !== lines.length - 1) blind = true; continue; } const cols = text.split(/\s+/); // THE POSITIVE COMES FIRST, and needs only the fields that carry it: a local // endpoint on the port we asked about, in the LISTEN state. Truncation AFTER // the state cannot unmake the fields before it, so a row cut short of `inode` // still proves the socket it already showed us. Putting the completeness gate // ahead of this would be the same fold as everywhere else in this function, // merely pointed at a positive: withholding a finding we actually made. const prefix = parseKernelRowPrefix(cols, addressHexWidth); const matchesPort = prefix != null && prefix.localPort.toUpperCase() === wanted && prefix.state.toUpperCase() === LISTEN_STATE; if (matchesPort) sawListener = true; // Everything below serves the NEGATIVE, which is the half that needs the row // to be whole — and the inode, which needs the same wholeness for its own // reason (see KernelTableReading). Scanning ON past a positive costs one more // table read and cannot unmake `sawListener`, which is spent before `blind` // below: a socket we have already seen stays seen however damaged the rest is. const row = parseKernelRow(cols, addressHexWidth); if (row === undefined) { blind = true; continue; } if (matchesPort && row.inode != null && // A LISTEN row always carries a real inode. `0` is what a socket with no // inode of its own prints (a TIME_WAIT/request socket), and `socket:[0]` // is not something any `/proc/<pid>/fd` entry points at — matching on it // could only ever produce a coincidence, never an owner. row.inode !== "0" && kernelRowServesHost(row.localAddressHex, host)) { inodes.push(row.inode); } // The kernel numbers rows with a running counter from 0, so the sequence is // its own continuity check: a GAP means a record between these two is missing // from what we read, and a first row numbered above 0 means we picked the // table up after its beginning. Neither is visible in any single row. if (row.slot !== nextSlot) blind = true; nextSlot = row.slot + 1; } // No header on the first line means we did not see where the table STARTS — rows // before the point we picked it up may be missing, and a well-formed row proves // only that the bytes we got were well-formed. No trailing newline means we did // not see where it ENDS. Either way this read cannot support a definite negative. if (!sawHeader || !raw.endsWith("\n")) blind = true; } // A POSITIVE outranks everything, including a blind spot: a socket we saw is a // socket, however much else was lost. Then a blind spot outranks the negative — it // means something could still be hiding. Only when nothing could be — every table // either read cleanly or provably does not exist — may the negative be spent. const existence = sawListener ? "listening" : blind ? "incomplete" : tablesRead === 0 ? "unavailable" : "none"; return { existence, inodes }; } /** Did this read fail because the process is GONE, or because we could not look? */ function pidVanished(err) { const code = err?.code; // ENOENT: the pid exited between listing /proc and reading its fds — it cannot be // holding the socket. ESRCH is the same fact by another name. Anything else // (EACCES/EPERM on another user's process, and an errno-less failure) is a place we // could not see into, and something could be hiding there. return code === "ENOENT" || code === "ESRCH"; } function attributeInodes(inodes, port) { const wanted = new Map(inodes.map((inode) => [`socket:[${inode}]`, inode])); let pids; try { pids = procFdReader.listPids(); } catch (err) { return { state: "unattributed", reason: `a socket is listening on port ${port} but /proc could not be enumerated to name its owner (${err instanceof Error ? err.message : String(err)})`, }; } const owners = new Map(); let blind = false; for (const pid of pids) { let fds; try { fds = procFdReader.listFds(pid); } catch (err) { if (!pidVanished(err)) blind = true; continue; } for (const fd of fds) { let link; try { link = procFdReader.readFdLink(pid, fd); } catch (err) { // A single fd closing mid-walk is ordinary; a permission wall is not. if (!pidVanished(err)) blind = true; continue; } const inode = wanted.get(link); if (inode !== undefined) { // NO EARLY BREAK. Stopping at the first match records an ARBITRARY one of the // pid's listening sockets, and a later re-check asking "does it still hold I?" // would then be answered by whichever socket the walk happened to reach first // — so a process that released I while acquiring another listener on the same // port would still look unchanged (codex gate round 11). const seen = owners.get(pid); if (seen) { if (!seen.includes(inode)) seen.push(inode); } else { owners.set(pid, [inode]); } } } } if (owners.size === 1) { const [pid, inodes] = [...owners][0]; return { state: "owned", pid, inode: inodes[0], inodes }; } if (owners.size > 1) { return { state: "unattributed", reason: `the socket listening on port ${port} is held by several processes (${[ ...owners.keys(), ] .sort((a, b) => a - b) .join(", ")}), so no single process owns it`, }; } return { state: "unattributed", reason: blind ? `a socket is listening on port ${port} but the /proc entries that would name its owner could not all be read (it belongs to another user, or the process list changed underneath us)` : `a socket is listening on port ${port} but no process in /proc holds it`, }; } export function probePortOwner(port, host) { if (IS_WIN) { let ranSomething = false; const failures = []; try { const out = execSync(`netstat -ano -p TCP`, { encoding: "utf-8", timeout: 5000, }); ranSomething = true; const pid = parseListenerPidFromNetstat(out, port, host); if (pid) return { state: "owned", pid }; } catch (err) { failures.push(`netstat: ${err instanceof Error ? err.message : String(err)}`); } try { const out = execSync(`powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | ForEach-Object { \\"$($_.LocalAddress) $($_.OwningProcess)\\" }"`, { encoding: "utf-8", timeout: 8000 }); ranSomething = true; for (const raw of out.split(/\r?\n/)) { const [addr, pidText] = raw.trim().split(/\s+/); if (!addr || !pidText) continue; if (!addressServesHost(addr.replace(/^\[|\]$/g, ""), host)) continue; const pid = parseInt(pidText, 10); if (!Number.isNaN(pid) && pid > 0) return { state: "owned", pid }; } } catch (err) { failures.push(`powershell: ${err instanceof Error ? err.message : String(err)}`); } // A probe that RAN and matched nothing is real evidence the port is free. // Every probe failing is not. return ranSomething ? { state: "free" } : { state: "unknown", reason: failures.join("; ") || "no port probe available" }; } // THE PRECEDENCE BETWEEN THE TWO SOURCES — stated here because it is the thing a // future edit will get wrong, and because getting it wrong certifies a stop that // never happened: // // EXISTENCE is decided by the kernel table wherever it is available. // lsof only ATTRIBUTES an existing socket to a pid. // // The kernel table sees every socket regardless of owner and cannot be silenced — // that is the entire reason it is consulted. lsof needs privileges the table does // not, so lsof failing to find or name a listener the kernel CAN see means "I // could not identify it", never "it is not there". A positive existence finding is // therefore never overridable by a negative from a source that sees strictly less. const reading = readKernelSocketTables(port, host); const kernel = reading.existence; if (kernel === "none") return { state: "free" }; // ATTRIBUTION FROM THE SAME SOURCE (#795). The kernel table already handed us the // listening socket's inode; `/proc/<pid>/fd` says which process holds it. Consulted // BEFORE lsof because it is the same evidence one step further along, needs no // external binary, and reaches exactly as far as an unprivileged lsof does — so on // a host with no lsof at all this is the difference between an identified server // and a permanent "cannot confirm". It never answers `free`: existence was settled // above, and failing to NAME an owner is not evidence there is none. let procNote; if (reading.inodes.length > 0) { const attributed = attributeInodes(reading.inodes, port); if (attributed.state === "owned") { // BRACKET THE JOIN (codex gate). The inode came from one read and the fd walk // is another, and a socket inode is reusable the moment its socket closes: the // listener can go away between the two, its number be handed to an unrelated // socket, and the walk then name whatever process holds THAT. Nothing later // would catch it on the path where the server is wedged and has no argv to // corroborate against — and the pid is what a stop kills. // // So re-read the table and require the SAME inode to still be listening on the // port. This is the same discipline the restart path already applies to the // /system_stats pairing: carry something across two observations that a // substitution cannot reproduce. const after = readKernelSocketTables(port, host); // THE WHOLE FINDING HAS TO REPRODUCE, not just a part of it. // // The claim being bracketed is "exactly one visible process holds THIS socket", // so the closing check re-derives exactly that. Two weaker versions were both // wrong, in the same way: // • confirming only that a listener is still on the port accepts a REPLACEMENT // socket that reused the inode; // • confirming only that OUR pid still holds the inode accepts a SECOND holder // appearing beside it — which the opening scan would have refused as // ambiguous, so the bracket would have laundered a verdict the same evidence // was not allowed to produce a moment earlier (codex gate round 10). // A full re-attribution costs one more `/proc` walk on the positive path — the // same order as the lsof spawn it replaces — and is the only form that cannot // certify something the first pass would have rejected. // THREE conditions, which together are exactly the claim and no more: the socket // we attributed is still listening, re-deriving the owner still yields THIS pid // alone, and that pid still holds THAT socket. // // The third is not the same as "the walk selected the same inode again" — which // is about fd ordering and would fail a dual-stack listener spuriously. It asks // whether I is in the pid's complete set. Dropping it entirely (as an earlier // revision did) reopens the substitution from the other side: A could release I // while holding another listener J on the same port, an unreadable B could take // I, and re-attribution would return A/J alone — proving A owns *a* listener // while never proving it still owns the one being bracketed (codex gate r11). const stillListening = after.inodes.includes(attributed.inode); const recheck = stillListening ? attributeInodes(after.inodes, port) : undefined; if (recheck?.state === "owned" && recheck.pid === attributed.pid && recheck.inodes.includes(attributed.inode)) { return { state: "owned", pid: attributed.pid }; } // Each way the re-check can fail is a DIFFERENT fact about the world, and the // reason has to say which — "changed hands (PID 4321, then PID 4321)" is what // comes out of collapsing the same-pid-different-socket case into the // changed-owner one. procNote = !stillListening ? `the socket listening on port ${port} changed while its owner was being identified, ` + `so PID ${attributed.pid} cannot be tied to it` : recheck?.state === "owned" && recheck.pid !== attributed.pid ? `the socket listening on port ${port} changed hands while its owner was being ` + `identified (PID ${attributed.pid}, then PID ${recheck.pid})` : recheck?.state === "owned" ? `PID ${attributed.pid} no longer holds the socket it was identified by on port ` + `${port} — it holds another listener there, which is not the same finding` : `PID ${attributed.pid} could not be re-confirmed as the owner of the socket ` + `listening on port ${port}: ${recheck?.reason ?? "the re-check could not be performed"}`; } else { procNote = attributed.reason; } } const listeningButUnnamed = { state: "unknown", reason: "a socket is listening on the port but lsof could not name its owner" + (procNote ? `, and ${procNote}` : ""), }; /** * lsof positively STATED that nothing is listening. Believable only when the * kernel has neither contradicted it nor left a blind spot lsof could share: an * unprivileged lsof reports exactly this while being unable to enumerate another * user's listener, so it may not be spent as proof against a table we could not * finish reading. */ const lsofReportsNothingListening = () => { if (kernel === "listening") return listeningButUnnamed; if (kernel === "incomplete") { return { state: "unknown", reason: "lsof found nothing, but a kernel socket table could not be read in full — an unreadable table may hold the listener", }; } return { state: "free" }; }; // WHICH HALF CARRIES THE GUARANTEE — stated here because the two sources below do // NOT offer the same one, and a reader who assumes they do will over-trust this. // // The `/proc` attribution above brackets a pid against a SPECIFIC SOCKET: the inode // is re-read from the kernel table and the pid's complete fd set must still contain // it, so a socket that changed hands, or was released while its holder kept another // listener on the same port, withdraws the finding. // // `lsof -Fpn` cannot express that. Its records carry a pid and an address:port and // NO inode, so this fallback attributes BY PID ALONE — it can say "some process is // listening there and it is this one", not "this process holds the socket I was // tracking". The substitution the bracket closes is therefore closed for the /proc // path and NOT for this one. // // It is kept regardless, and deliberately: it is exactly the pre-existing behaviour // (this is how the port owner has always been named on hosts without a readable // /proc), so nothing is made worse, while removing it would restore the permanent // "cannot confirm" on every non-Linux and unprivileged host — the very state #795 // exists to shrink. The honest summary is that /proc attribution is stronger than // lsof attribution, not that both are airtight. try { const out = runLsofListenerQuery(port); const pid = parseListenerPidFromLsof(out, port, host); if (pid) return { state: "owned", pid }; const absence = readLsofAbsence(out); if (absence === "stated") return lsofReportsNothingListening(); // It DID report an empty search; we declined to spend it. Say that, rather than // reporting the one cause we know did not apply. if (absence === "inconclusive") { return { state: "unknown", reason: LSOF_INCONCLUSIVE_REASON }; } // Exited 0 but named nobody, and did not say it looked and found nothing. return kernel === "listening" ? listeningButUnnamed : { state: "unknown", reason: "lsof listed no owner and did not report an empty search" }; } catch (err) { const absence = lsofErrorAbsence(err); if (absence === "stated") return lsofReportsNothingListening(); if (absence === "inconclusive") { return { state: "unknown", reason: LSOF_INCONCLUSIVE_REASON }; } return { state: "unknown", reason: // The /proc finding leads when there is one: on a host with no lsof at all // (#795's whole subject) "lsof: spawn ENOENT" says nothing about the port, // while "a socket is listening but its owner is another user's" does. (procNote ? `${procNote}; ` : "") + `lsof: ${err instanceof Error ? err.message : String(err)}`, }; } } export function findPidByPort(port, host) { const probe = probePortOwner(port, host); return probe.state === "owned" ? probe.pid : null; } //# sourceMappingURL=port-owner.js.map