6ad2e7a393
Previously, provider HTTP requests had no application-level timeout unless explicitly configured. Combined with Bun's fetch timeout being disabled, requests could hang indefinitely when the network was down, waiting for the OS TCP stack to give up. Add REQUEST_TIMEOUT_MS (120s) as the default timeout applied via AbortSignal.timeout() in the provider fetch wrapper. Recognize TimeoutError as a network disconnection so it enters the offline prompt flow.
136 lines
4.9 KiB
TypeScript
136 lines
4.9 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { Bus } from "../../src/bus"
|
|
import { Instance } from "../../src/project/instance"
|
|
import { tmpdir } from "../fixture/fixture"
|
|
import { SessionNetwork } from "../../src/session/network"
|
|
|
|
describe("session.network", () => {
|
|
test("detects common network disconnect codes", () => {
|
|
expect(SessionNetwork.disconnected({ code: "ECONNREFUSED" })).toBe(true)
|
|
expect(SessionNetwork.disconnected({ code: "ENOTFOUND" })).toBe(true)
|
|
expect(SessionNetwork.disconnected({ code: "EAI_AGAIN" })).toBe(true)
|
|
expect(SessionNetwork.disconnected({ code: "ENOENT" })).toBe(false)
|
|
})
|
|
|
|
test("detects provider unable to connect message", () => {
|
|
const err = new Error("Unable to connect. Is the computer able to access the url?")
|
|
expect(SessionNetwork.disconnected(err)).toBe(true)
|
|
expect(SessionNetwork.message(err)).toBe("Unable to connect. Is the computer able to access the url?")
|
|
})
|
|
|
|
test("detects wrapped network cause", () => {
|
|
const err = new Error("top") as Error & { cause?: unknown }
|
|
err.cause = { code: "ETIMEDOUT" }
|
|
expect(SessionNetwork.disconnected(err)).toBe(true)
|
|
expect(SessionNetwork.message(err)).toBe("Connection timed out")
|
|
})
|
|
|
|
test("detects TimeoutError as disconnected", () => {
|
|
const err = new DOMException("The operation was aborted due to timeout", "TimeoutError")
|
|
expect(SessionNetwork.disconnected(err)).toBe(true)
|
|
expect(SessionNetwork.message(err)).toBe("Request timed out")
|
|
})
|
|
|
|
test("detects wrapped TimeoutError in cause chain", () => {
|
|
const timeout = new DOMException("signal timed out", "TimeoutError")
|
|
const err = new Error("request failed", { cause: timeout })
|
|
expect(SessionNetwork.disconnected(err)).toBe(true)
|
|
expect(SessionNetwork.message(err)).toBe("Request timed out")
|
|
})
|
|
|
|
test("reply resolves pending request", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const ask = SessionNetwork.ask({
|
|
sessionID: "ses_test",
|
|
message: "Connection refused",
|
|
abort: new AbortController().signal,
|
|
})
|
|
const pending = await SessionNetwork.list()
|
|
expect(pending).toHaveLength(1)
|
|
const req = pending[0]!
|
|
await SessionNetwork.reply({ requestID: req.id })
|
|
await expect(ask).resolves.toBeUndefined()
|
|
},
|
|
})
|
|
})
|
|
|
|
test("reject rejects pending request", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const ask = SessionNetwork.ask({
|
|
sessionID: "ses_test",
|
|
message: "Connection timed out",
|
|
abort: new AbortController().signal,
|
|
})
|
|
const pending = await SessionNetwork.list()
|
|
expect(pending).toHaveLength(1)
|
|
const req = pending[0]!
|
|
await SessionNetwork.reject({ requestID: req.id })
|
|
await expect(ask).rejects.toBeInstanceOf(SessionNetwork.RejectedError)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("aborted signal rejects without publishing asked", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const abort = new AbortController()
|
|
const seen: string[] = []
|
|
const offAsked = Bus.subscribe(SessionNetwork.Event.Asked, () => seen.push("asked"))
|
|
const offRejected = Bus.subscribe(SessionNetwork.Event.Rejected, () => seen.push("rejected"))
|
|
abort.abort()
|
|
|
|
try {
|
|
const err = await SessionNetwork.ask({
|
|
sessionID: "ses_test",
|
|
message: "Connection timed out",
|
|
abort: abort.signal,
|
|
}).catch((err) => err)
|
|
|
|
expect(err).toBeInstanceOf(DOMException)
|
|
expect(err.name).toBe("AbortError")
|
|
expect(await SessionNetwork.list()).toHaveLength(0)
|
|
expect(seen).toStrictEqual(["rejected"])
|
|
} finally {
|
|
offAsked()
|
|
offRejected()
|
|
}
|
|
},
|
|
})
|
|
})
|
|
|
|
test("abort during pending ask rejects with AbortError and cleans up", async () => {
|
|
await using tmp = await tmpdir({ git: true })
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const abort = new AbortController()
|
|
const pending = SessionNetwork.ask({
|
|
sessionID: "ses_test",
|
|
message: "Connection refused",
|
|
abort: abort.signal,
|
|
})
|
|
// wait for the ask to register
|
|
const list = await SessionNetwork.list()
|
|
expect(list).toHaveLength(1)
|
|
|
|
// abort while waiting
|
|
abort.abort()
|
|
const err = await pending.catch((e: unknown) => e)
|
|
expect(err).toBeInstanceOf(DOMException)
|
|
expect((err as DOMException).name).toBe("AbortError")
|
|
|
|
// pending entry should be cleaned up
|
|
expect(await SessionNetwork.list()).toHaveLength(0)
|
|
},
|
|
})
|
|
})
|
|
})
|