From ad8d57707d0772e4e417a9f611ad35d487dd9ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 18 May 2026 10:14:43 -0300 Subject: [PATCH] fix(cli): address network reconnect review --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 2 +- .../cli/cmd/tui/routes/session/network.tsx | 7 +++- .../src/kilocode/provider/provider.ts | 2 +- packages/opencode/src/session/network.ts | 25 ++++++++--- .../opencode/test/session/network.test.ts | 41 +++++++++++++++++-- 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 5527d80280b..9eef7acf58e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -298,7 +298,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const match = Binary.search(requests, event.properties.requestID, (r) => r.id) if (match.found) { setStore("network", event.properties.sessionID, match.index, "restored", true) - setStore("network", event.properties.sessionID, match.index, "time", "restored", event.properties.time) + setStore("network", event.properties.sessionID, match.index, "time", "restored", event.properties.time) // kilocode_change } break } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/network.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/network.tsx index 137aca79bb0..2ec2b160127 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/network.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/network.tsx @@ -29,7 +29,12 @@ export function NetworkPrompt(props: { request: SessionNetworkWait }) { setCountdown(10) return } - const restored = props.request.time.restored ?? Date.now() + const restored = props.request.time.restored + if (restored === undefined) { + console.warn("session.network restored without restore time", props.request.id) + setCountdown(0) + return + } const remaining = () => Math.max(0, 10 - Math.floor((Date.now() - restored) / 1000)) setCountdown(remaining()) const timer = setInterval(() => { diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 5d0fd637619..7d264ff87eb 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -14,7 +14,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import { mapValues, omit, pickBy } from "remeda" /** Default timeout (ms) for provider HTTP requests (connection phase). */ -export const REQUEST_TIMEOUT_MS = 180_000 // 3 minutes +export const REQUEST_TIMEOUT_MS = 300_000 // 5 minutes // --------------------------------------------------------------------------- // Bundled providers diff --git a/packages/opencode/src/session/network.ts b/packages/opencode/src/session/network.ts index 2f4fff6fd5b..7857f577fac 100644 --- a/packages/opencode/src/session/network.ts +++ b/packages/opencode/src/session/network.ts @@ -24,7 +24,6 @@ export namespace SessionNetwork { "ENETUNREACH", "EHOSTUNREACH", "ENETDOWN", - "EPIPE", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_SOCKET", @@ -108,6 +107,7 @@ export namespace SessionNetwork { QuestionID, { info: Types.Mutable + abort: AbortSignal resolve: () => void reject: (e: unknown) => void } @@ -182,7 +182,6 @@ export namespace SessionNetwork { if (match === "ENETUNREACH") return "Network is unreachable" if (match === "EHOSTUNREACH") return "Host is unreachable" if (match === "ENETDOWN") return "Network is down" - if (match === "EPIPE") return "Network connection closed" if (match === "UND_ERR_CONNECT_TIMEOUT") return "Connection timed out" if (match === "UND_ERR_HEADERS_TIMEOUT") return "Request timed out" if (match === "UND_ERR_SOCKET") return "Network socket failed" @@ -218,8 +217,23 @@ export namespace SessionNetwork { ).catch(() => false) } - async function resume(input: { requestID: QuestionID }) { - await new Promise((resolve) => setTimeout(resolve, RESUME_MS)) + async function delay(abort: AbortSignal) { + if (abort.aborted) return false + return new Promise((resolve) => { + const timer = setTimeout(() => { + abort.removeEventListener("abort", onAbort) + resolve(true) + }, RESUME_MS) + function onAbort() { + clearTimeout(timer) + resolve(false) + } + abort.addEventListener("abort", onAbort, { once: true }) + }) + } + + async function resume(input: { requestID: QuestionID; abort: AbortSignal }) { + if (!(await delay(input.abort))) return const s = await state() const req = s.pending.get(input.requestID) if (!req || !req.info.restored) return @@ -266,6 +280,7 @@ export namespace SessionNetwork { } s.pending.set(id, { info, + abort: input.abort, resolve: () => { input.abort.removeEventListener("abort", onAbort) resolve() @@ -307,7 +322,7 @@ export namespace SessionNetwork { requestID: req.info.id, time, }) - void resume({ requestID }).catch((err) => { + void resume({ requestID, abort: req.abort }).catch((err) => { log.error("auto resume failed", { err, requestID }) }) }, diff --git a/packages/opencode/test/session/network.test.ts b/packages/opencode/test/session/network.test.ts index fc3282f2411..1a1009514d0 100644 --- a/packages/opencode/test/session/network.test.ts +++ b/packages/opencode/test/session/network.test.ts @@ -7,18 +7,30 @@ import { SessionNetwork } from "../../src/session/network" import { SessionID } from "../../src/session/schema" const timer = globalThis.setTimeout +const clear = globalThis.clearTimeout afterEach(() => { globalThis.setTimeout = timer + globalThis.clearTimeout = clear }) function manual() { - const jobs: TimerHandler[] = [] + const state = { + next: 0, + jobs: new Map(), + } globalThis.setTimeout = ((cb: TimerHandler) => { - jobs.push(cb) - return 0 as unknown as ReturnType + const id = state.next + 1 + state.next = id + state.jobs.set(id, cb) + return id as unknown as ReturnType }) as unknown as typeof setTimeout + globalThis.clearTimeout = ((id: ReturnType) => { + state.jobs.delete(id as unknown as number) + }) as unknown as typeof clearTimeout return () => { + const jobs = Array.from(state.jobs.values()) + state.jobs.clear() for (const job of jobs) { if (typeof job === "function") job() } @@ -32,6 +44,7 @@ describe("session.network", () => { expect(SessionNetwork.disconnected({ code: "EAI_AGAIN" })).toBe(true) expect(SessionNetwork.disconnected({ code: "EHOSTUNREACH" })).toBe(true) expect(SessionNetwork.disconnected({ code: "UND_ERR_CONNECT_TIMEOUT" })).toBe(true) + expect(SessionNetwork.disconnected({ code: "EPIPE" })).toBe(false) expect(SessionNetwork.disconnected({ code: "ENOENT" })).toBe(false) }) @@ -136,6 +149,28 @@ describe("session.network", () => { }) }) + test("abort cancels restored auto-resume timer", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const run = manual() + const abort = new AbortController() + const { promise } = await SessionNetwork.ask({ + sessionID: SessionID.make("ses_test"), + message: "Connection refused", + abort: abort.signal, + }) + const req = (await SessionNetwork.list())[0]! + await SessionNetwork.restore({ requestID: req.id }) + abort.abort() + await expect(promise).rejects.toBeInstanceOf(DOMException) + run() + expect(await SessionNetwork.list()).toHaveLength(0) + }, + }) + }) + test("reject rejects pending request", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({