fix(cli): limit background process port scans

This commit is contained in:
Catriel Müller
2026-06-01 16:01:28 -03:00
parent f79f181bd7
commit 791515ea09
5 changed files with 100 additions and 36 deletions
+1 -1
View File
@@ -3,4 +3,4 @@
"kilo-code": patch
---
Skip inferred background-process port scanning in VS Code sessions to avoid unnecessary Bun subprocess polling.
Limit inferred background-process port discovery to the TUI and stop scanning after startup to avoid unnecessary Bun subprocess polling.
@@ -176,6 +176,7 @@ export const TuiThreadCommand = cmd({
const env = sanitizedProcessEnv({
[KILO_PROCESS_ROLE]: "worker",
[KILO_RUN_ID]: ensureRunID(),
KILO_BACKGROUND_PROCESS_PORTS: "true", // kilocode_change - TUI surfaces inferred background process ports
})
const worker = new Worker(file, {
@@ -23,7 +23,9 @@ export namespace BackgroundProcess {
const KILL_MS = 3_000
const READY_MS = 30_000
const PUBLISH_MS = 500
const PORT_MS = 2_000
const PORT_START_MS = 500
const PORT_MS = 5_000
const PORT_LIMIT_MS = 30_000
const idSchema = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("bgp") }).pipe(
Schema.brand("BackgroundProcessID"),
@@ -165,7 +167,11 @@ export namespace BackgroundProcess {
return a.length === b.length && a.every((port, index) => port === b[index])
}
async function refresh(active: Active) {
function infer() {
return Flag.KILO_CLIENT === "cli" && process.env.KILO_BACKGROUND_PROCESS_PORTS === "true"
}
function update(active: Active, ports?: number[]) {
const pid = active.proc.pid
if (!pid || terminal(active.info.status)) {
const changed = active.info.ports.length > 0
@@ -173,14 +179,19 @@ export namespace BackgroundProcess {
return changed
}
const fallback = active.info.ready && active.start.ready?.port ? [active.start.ready.port] : []
const ports = Flag.KILO_CLIENT === "cli" ? await Ports.list(pid) : []
const next = Array.from(new Set([...ports, ...fallback])).toSorted((a, b) => a - b)
const next = Array.from(new Set([...(ports ?? active.info.ports), ...fallback])).toSorted((a, b) => a - b)
if (same(active.info.ports, next)) return false
active.info.ports = next
active.info.time.updated = Date.now()
return true
}
async function refresh(active: Active) {
const pid = active.proc.pid
if (!pid || terminal(active.info.status)) return update(active)
return update(active, await Ports.list(pid))
}
function emit(active: Active) {
Instance.restore(active.ctx, () => {
void Bus.publish(Event.Updated, { info: clone(active.info) }).catch((err) => {
@@ -191,49 +202,45 @@ export namespace BackgroundProcess {
function publish(active: Active) {
if (active.disposed) return
active.scan = (active.scan ?? refresh(active))
.then(() => {
update(active)
emit(active)
}
function done(active: Active) {
if (active.disposed) return true
if (!infer()) return true
if (terminal(active.info.status)) return true
if (active.info.ports.length > 0) return true
return Date.now() - active.info.time.started >= PORT_LIMIT_MS
}
function scan(active: Active) {
if (done(active)) return
if (active.scan) return
active.scan = refresh(active)
.then((changed) => {
active.scan = undefined
if (active.disposed) return false
emit(active)
if (changed) emit(active)
poll(active)
return false
return changed
})
.catch((err) => {
active.scan = undefined
if (active.disposed) return false
log.debug("failed to refresh process ports", { err, id: active.info.id })
emit(active)
poll(active)
return false
})
}
function poll(active: Active) {
if (active.disposed) return
if (Flag.KILO_CLIENT !== "cli") return
if (terminal(active.info.status)) return
function poll(active: Active, ms = PORT_MS) {
if (done(active)) return
if (active.poll) return
active.poll = setTimeout(() => {
active.poll = undefined
if (active.disposed) return
if (terminal(active.info.status)) return
active.scan = (active.scan ?? refresh(active))
.then((changed) => {
active.scan = undefined
if (active.disposed) return false
if (changed) emit(active)
poll(active)
return changed
})
.catch((err) => {
active.scan = undefined
if (active.disposed) return false
log.debug("failed to refresh process ports", { err, id: active.info.id })
poll(active)
return false
})
}, PORT_MS)
scan(active)
}, ms)
}
function schedule(active: Active) {
@@ -497,6 +504,7 @@ export namespace BackgroundProcess {
exited(active, code, signal)
})
publish(active)
poll(active, PORT_START_MS)
if (input.ready) await wait(active, input.ready)
return clone(active.info)
}
@@ -2,6 +2,8 @@ import { Process } from "@/util/process"
import fs from "fs/promises"
import path from "path"
const SCAN_MS = 1_000
function sorted(items: Iterable<number>) {
return Array.from(items).toSorted((a, b) => a - b)
}
@@ -92,7 +94,7 @@ async function linux(root: number) {
}
async function ps(root: number) {
const rows = await Process.lines(["ps", "-axo", "pid=,ppid="], { nothrow: true })
const rows = await lines(["ps", "-axo", "pid=,ppid="])
const children = new Map<number, number[]>()
for (const row of rows) {
const [pid, parent] = row.trim().split(/\s+/).map(Number)
@@ -115,9 +117,7 @@ async function ps(root: number) {
async function lsof(root: number) {
const pids = await ps(root).catch(() => new Set([root]))
const rows = await Process.lines(["lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", Array.from(pids).join(",")], {
nothrow: true,
})
const rows = await lines(["lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", Array.from(pids).join(",")])
return sorted(
new Set(
rows.flatMap((row) => {
@@ -128,6 +128,17 @@ async function lsof(root: number) {
)
}
async function lines(cmd: string[]) {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), SCAN_MS)
timer.unref?.()
try {
return await Process.lines(cmd, { nothrow: true, abort: ctrl.signal, timeout: 500 })
} finally {
clearTimeout(timer)
}
}
export async function list(root: number) {
if (process.platform === "linux") {
const ports = await linux(root).catch(() => [])
@@ -150,7 +150,9 @@ setInterval(() => {}, 1_000)
),
)
const client = process.env["KILO_CLIENT"]
const scans = process.env["KILO_BACKGROUND_PROCESS_PORTS"]
process.env["KILO_CLIENT"] = "cli"
process.env["KILO_BACKGROUND_PROCESS_PORTS"] = "true"
try {
const info = yield* Effect.promise(() =>
@@ -173,6 +175,48 @@ setInterval(() => {}, 1_000)
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID))
if (client === undefined) delete process.env["KILO_CLIENT"]
else process.env["KILO_CLIENT"] = client
if (scans === undefined) delete process.env["KILO_BACKGROUND_PROCESS_PORTS"]
else process.env["KILO_BACKGROUND_PROCESS_PORTS"] = scans
}
}),
)
it.instance("does not infer ports for CLI clients without opt in", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = SessionID.descending()
const listen = port()
const command = yield* Effect.promise(() =>
script(
test.directory,
"cli-no-port.mjs",
`Bun.serve({ hostname: "127.0.0.1", port: ${listen}, fetch: () => new Response() })
`,
),
)
const client = process.env["KILO_CLIENT"]
const scans = process.env["KILO_BACKGROUND_PROCESS_PORTS"]
process.env["KILO_CLIENT"] = "cli"
delete process.env["KILO_BACKGROUND_PROCESS_PORTS"]
try {
const info = yield* Effect.promise(() =>
BackgroundProcess.start({
sessionID,
command,
cwd: test.directory,
}),
)
yield* Effect.promise(() => Bun.sleep(1_000))
const found = yield* Effect.promise(() => BackgroundProcess.get(info.id))
expect(found?.ports).toEqual([])
} finally {
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID))
if (client === undefined) delete process.env["KILO_CLIENT"]
else process.env["KILO_CLIENT"] = client
if (scans === undefined) delete process.env["KILO_BACKGROUND_PROCESS_PORTS"]
else process.env["KILO_BACKGROUND_PROCESS_PORTS"] = scans
}
}),
)