diff --git a/.changeset/calm-background-process-scans.md b/.changeset/calm-background-process-scans.md index 3b32864b9f..8fb7d3f34e 100644 --- a/.changeset/calm-background-process-scans.md +++ b/.changeset/calm-background-process-scans.md @@ -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. diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 23beb6fa2b..ec663c2a69 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -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, { diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts index cafef91183..9811cbb4f3 100644 --- a/packages/opencode/src/kilocode/background-process/index.ts +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -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) } diff --git a/packages/opencode/src/kilocode/background-process/ports.ts b/packages/opencode/src/kilocode/background-process/ports.ts index 7afa14e11e..c5049d790e 100644 --- a/packages/opencode/src/kilocode/background-process/ports.ts +++ b/packages/opencode/src/kilocode/background-process/ports.ts @@ -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) { 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() 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(() => []) diff --git a/packages/opencode/test/kilocode/background-process.test.ts b/packages/opencode/test/kilocode/background-process.test.ts index 0e785e4485..a457f1d528 100644 --- a/packages/opencode/test/kilocode/background-process.test.ts +++ b/packages/opencode/test/kilocode/background-process.test.ts @@ -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 } }), )