From 1f098c9c6ab88a0b0050bd0ed79a75400d1808a7 Mon Sep 17 00:00:00 2001 From: Anton Dziatkovskii <194927794+tonydzi@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:36:27 +0000 Subject: [PATCH] fix(flow): free only the exact port on Windows, not every port it prefixes freePort matched `netstat -ano` lines with `line.includes(":4096")`, so a process listening on :40961 (or :51740 for the canvas port 5174) was handed to `taskkill /T /F` along with its whole tree. It also required the literal state "LISTENING", which netstat translates on non-English Windows, so there the stale port was never freed at all. listeningPids() in lib/launch.ts compares the local-address column exactly and recognises a listener by its foreign address (0.0.0.0:0 / [::]:0). Covered by unit tests in launch.test.ts. Assisted-by: Claude Code / claude-opus-5-5 Machine: A-2022BAYAREA Account: tonydzi Operator: Anton Co-Authored-By: Claude Opus 5.5 --- openflow.ts | 12 ++---------- packages/flow/lib/launch.test.ts | 29 ++++++++++++++++++++++++++++- packages/flow/lib/launch.ts | 25 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/openflow.ts b/openflow.ts index 5c86880af..75f4b9d77 100644 --- a/openflow.ts +++ b/openflow.ts @@ -18,7 +18,7 @@ * `packages/flow/lib/launch.ts` (unit-tested); this file only spawns, streams, * waits, and tears down. */ -import { launchPlan } from "./packages/flow/lib/launch" +import { launchPlan, listeningPids } from "./packages/flow/lib/launch" import { healthy } from "./packages/flow/lib/opencode-process" const repo = import.meta.dir @@ -56,15 +56,7 @@ async function pipe(stream: ReadableStream | number | undefined, pre function freePort(port: string) { if (process.platform === "win32") { const { stdout } = Bun.spawnSync(["netstat", "-ano"], { stdout: "pipe" }) - const pids = new Set( - stdout - .toString() - .split(/\r?\n/) - .filter((line) => line.includes(`:${port}`) && line.includes("LISTENING")) - .map((line) => line.trim().split(/\s+/).pop()) - .filter((pid): pid is string => !!pid && pid !== "0"), - ) - for (const pid of pids) Bun.spawnSync(["taskkill", "/pid", pid, "/T", "/F"], { stdout: "ignore", stderr: "ignore" }) + for (const pid of listeningPids(stdout.toString(), port)) Bun.spawnSync(["taskkill", "/pid", pid, "/T", "/F"], { stdout: "ignore", stderr: "ignore" }) return } const { stdout } = Bun.spawnSync(["lsof", "-ti", `tcp:${port}`], { stdout: "pipe" }) diff --git a/packages/flow/lib/launch.test.ts b/packages/flow/lib/launch.test.ts index d8faa6378..a3ab5dd45 100644 --- a/packages/flow/lib/launch.test.ts +++ b/packages/flow/lib/launch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { launchPlan } from "./launch" +import { launchPlan, listeningPids } from "./launch" const repo = "/repo" @@ -103,3 +103,30 @@ describe("launchPlan", () => { expect(plan.engine.skip).toBe(false) }) }) + +describe("listeningPids", () => { + const netstat = [ + "Active Connections", + "", + " Proto Local Address Foreign Address State PID", + " TCP 0.0.0.0:4096 0.0.0.0:0 LISTENING 100", + " TCP [::]:4096 [::]:0 LISTENING 100", + " TCP 0.0.0.0:40961 0.0.0.0:0 LISTENING 200", + " TCP 127.0.0.1:51234 127.0.0.1:4096 ESTABLISHED 300", + " TCP 0.0.0.0:5174 0.0.0.0:0 ABHÖREN 400", + " UDP 0.0.0.0:4096 *:* 500", + ].join("\r\n") + + test("matches the port exactly, not as a prefix of a longer one", () => { + expect(listeningPids(netstat, "4096")).toEqual(["100"]) + expect(listeningPids(netstat, "409")).toEqual([]) + }) + + test("ignores a connection whose remote end is the port", () => { + expect(listeningPids(netstat, "51234")).toEqual([]) + }) + + test("finds a listener whatever language the state column is in", () => { + expect(listeningPids(netstat, "5174")).toEqual(["400"]) + }) +}) diff --git a/packages/flow/lib/launch.ts b/packages/flow/lib/launch.ts index 56839995a..5656e8f80 100644 --- a/packages/flow/lib/launch.ts +++ b/packages/flow/lib/launch.ts @@ -92,3 +92,28 @@ export async function launchPlan(input: { built, } } + +/** + * PIDs listening on exactly `port`, read from Windows `netstat -ano` output. + * + * The port is compared against the local-address column rather than searched + * for in the line: `:4096` is a substring of `:40961`, so a substring test hands + * an unrelated process to `taskkill /T /F`. A listening socket is told by its + * foreign address (`0.0.0.0:0` or `[::]:0`), not by the state column, because + * that column is translated on non-English Windows. + */ +export function listeningPids(netstat: string, port: string) { + return [ + ...new Set( + netstat + .split(/\r?\n/) + .map((line) => line.trim().split(/\s+/)) + .filter( + ([proto, local, foreign]) => + proto === "TCP" && local?.endsWith(`:${port}`) && (foreign === "0.0.0.0:0" || foreign === "[::]:0"), + ) + .map((columns) => columns.at(-1)) + .filter((pid): pid is string => !!pid && pid !== "0"), + ), + ] +}