fix(tui): surface chat connection failures (#5543)

* fix(tui): surface chat connection failures

* fix(tui): keep connection copy implementation-agnostic

* test(tui): avoid millisecond timing boundary

* fix(tui): use gateway health for connection status

* fix(tui): use product-level readiness copy
This commit is contained in:
chengyongru
2026-08-27 11:37:30 +08:00
committed by GitHub
parent 55f85b3c1f
commit 3a62b0b744
7 changed files with 615 additions and 48 deletions
+196 -1
View File
@@ -3,14 +3,19 @@ import { describe, expect, test } from "bun:test"
import {
NanobotClient,
GatewayConnectionError,
connectionEndpoint,
fetchAvailableSkills,
fetchGatewayConnection,
fetchGatewayHealth,
fetchHistory,
fetchMentionCandidates,
fetchRuntimeControls,
fetchSessionContext,
fetchSessions,
fetchSlashCommands,
sanitizeConnectionFailure,
type ConnectionStatus,
type ConnectionStatusInfo,
type InboundEvent,
} from "./protocol"
@@ -39,6 +44,12 @@ class FakeSocket {
}
}
async function waitUntil(predicate: () => boolean, timeout = 1_000): Promise<void> {
const deadline = Date.now() + timeout
while (!predicate() && Date.now() < deadline) await Bun.sleep(2)
if (!predicate()) throw new Error(`condition was not met within ${timeout}ms`)
}
describe("gateway protocol", () => {
test("bootstraps fresh websocket and API credentials", async () => {
const original = globalThis.fetch
@@ -70,6 +81,41 @@ describe("gateway protocol", () => {
}
})
test("classifies gateway health without sending credentials", async () => {
const original = globalThis.fetch
const requests: Array<{ url: string; headers: Headers }> = []
const responses = [
new Response(JSON.stringify({
status: "degraded",
process: "alive",
ready: false,
websocket: "unavailable",
}), { status: 503 }),
new Response(JSON.stringify({
status: "ok",
process: "alive",
ready: true,
websocket: "running",
})),
new Response("not json"),
]
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
requests.push({ url: String(input), headers: new Headers(init?.headers) })
return Promise.resolve(responses.shift() || new Response("missing", { status: 500 }))
}) as typeof fetch
try {
const healthUrl = "http://127.0.0.1:18790/health"
expect(await fetchGatewayHealth(healthUrl)).toBe("degraded")
expect(await fetchGatewayHealth(healthUrl)).toBe("ready")
expect(await fetchGatewayHealth(healthUrl)).toBe("unreachable")
expect(requests.map(({ url }) => url)).toEqual([healthUrl, healthUrl, healthUrl])
expect(requests.every(({ headers }) => [...headers].length === 0)).toBe(true)
} finally {
globalThis.fetch = original
}
})
test("rejects malformed bootstrap responses without retrying", async () => {
const original = globalThis.fetch
globalThis.fetch = (() => Promise.resolve(new Response("not json"))) as unknown as typeof fetch
@@ -138,8 +184,13 @@ describe("gateway protocol", () => {
try {
const connections: string[] = []
let healthChecks = 0
const client = new NanobotClient({
resolveConnection: () => new Promise((resolve) => { resolveConnection = resolve }),
checkHealth: async () => {
healthChecks += 1
return "ready"
},
onConnection: (connection) => connections.push(connection.apiToken),
onEvent: () => undefined,
onStatus: () => undefined,
@@ -155,6 +206,7 @@ describe("gateway protocol", () => {
await Bun.sleep(1)
expect(requestedUrl).toBe("ws://nanobot.test/ws?token=fresh")
expect(connections).toEqual(["fresh-api-token"])
expect(healthChecks).toBe(0)
client.close()
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
@@ -200,6 +252,133 @@ describe("gateway protocol", () => {
}
})
test("escalates a refused bootstrap with safe endpoint and retry diagnostics", async () => {
const original = globalThis.fetch
const bootstrapUrl = "http://bootstrap-user:bootstrap-pass@127.0.0.1:8769"
+ "/webui/bootstrap?token=socket-secret"
const bootstrapSecret = "bootstrap-secret"
const statuses: Array<{
status: ConnectionStatus
detail?: string
info?: ConnectionStatusInfo
}> = []
const refused = new TypeError(
`fetch failed for ${bootstrapUrl}&api_token=api-secret`,
{
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:8769"), {
code: "ECONNREFUSED",
}),
},
)
globalThis.fetch = (() => Promise.reject(refused)) as unknown as typeof fetch
const client = new NanobotClient({
resolveConnection: () => fetchGatewayConnection(
bootstrapUrl,
bootstrapSecret,
"http://127.0.0.1:8769",
"tui-42",
),
targetEndpoint: connectionEndpoint(bootstrapUrl),
checkHealth: async () => "degraded",
startupFailureDelayMs: 8,
reconnectDelayMs: 100,
onEvent: () => undefined,
onStatus: (status, detail, info) => statuses.push({ status, detail, info }),
})
try {
client.connect()
await waitUntil(() => statuses.some(({ status }) => status === "unavailable"))
const failure = [...statuses].reverse().find(({ status }) => status === "unavailable")
expect(statuses[0]?.status).toBe("starting")
expect(failure?.detail).toBe("connection refused")
expect(failure?.info).toMatchObject({
endpoint: "127.0.0.1:8769",
attempt: 1,
elapsedMs: expect.any(Number),
health: "degraded",
})
const visible = JSON.stringify(statuses)
expect(visible).not.toContain("bootstrap-user")
expect(visible).not.toContain("bootstrap-pass")
expect(visible).not.toContain(bootstrapSecret)
expect(visible).not.toContain("socket-secret")
expect(visible).not.toContain("api-secret")
expect(visible).not.toContain("/webui/bootstrap")
} finally {
client.close()
globalThis.fetch = original
}
})
test("recovers after sustained bootstrap failures without hiding the outage", async () => {
const original = globalThis.WebSocket
const sockets: FakeSocket[] = []
let available = false
let attempts = 0
const statuses: ConnectionStatus[] = []
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor() {
super()
sockets.push(this)
}
},
})
const client = new NanobotClient({
resolveConnection: async () => {
attempts += 1
if (!available) {
throw Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" })
}
return {
wsUrl: "ws://127.0.0.1:8769/ws?token=fresh",
apiUrl: "http://127.0.0.1:8769",
apiToken: "fresh-api-token",
}
},
targetEndpoint: "127.0.0.1:8769",
checkHealth: async () => available ? "ready" : "degraded",
startupFailureDelayMs: 8,
reconnectDelayMs: 2,
startupRetryMaxDelayMs: 2,
onEvent: () => undefined,
onStatus: (status) => statuses.push(status),
})
try {
client.connect()
await waitUntil(() => statuses.includes("unavailable"))
available = true
await waitUntil(() => sockets.length === 1)
sockets[0]?.emit("open")
await waitUntil(() => statuses.at(-1) === "connected")
expect(attempts).toBeGreaterThan(1)
expect(statuses.indexOf("starting")).toBeLessThan(statuses.indexOf("unavailable"))
expect(statuses.indexOf("unavailable")).toBeLessThan(statuses.lastIndexOf("connected"))
} finally {
client.close()
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("sanitizes arbitrary connection errors and authenticated URLs", () => {
const authenticated = "wss://user:password@127.0.0.1:8769/ws"
+ "?token=socket-secret&api_token=api-secret"
const unknown = new Error(`could not reach ${authenticated}`)
const refused = Object.assign(new Error(`ECONNREFUSED ${authenticated}`), {
code: "ECONNREFUSED",
})
expect(connectionEndpoint(authenticated)).toBe("127.0.0.1:8769")
expect(sanitizeConnectionFailure(unknown)).toBe("connection failed")
expect(sanitizeConnectionFailure(refused)).toBe("connection refused")
expect(sanitizeConnectionFailure(unknown)).not.toContain("socket-secret")
})
test("reports a permanent bootstrap rejection without retrying", async () => {
let attempts = 0
const statuses: string[] = []
@@ -588,13 +767,19 @@ describe("gateway protocol", () => {
})
try {
const statuses: Array<{
status: ConnectionStatus
detail?: string
info?: ConnectionStatusInfo
}> = []
const client = new NanobotClient({
url: "ws://nanobot.test/ws",
reconnectDelayMs: 1,
onEvent: () => undefined,
onStatus: () => undefined,
onStatus: (status, detail, info) => statuses.push({ status, detail, info }),
})
client.connect()
sockets[0]?.emit("open")
sockets[0]?.emit("message", {
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client" }),
})
@@ -605,11 +790,21 @@ describe("gateway protocol", () => {
await Bun.sleep(5)
expect(sockets).toHaveLength(2)
const reconnecting = [...statuses].reverse().find(
({ status }) => status === "reconnecting",
)
expect(reconnecting).toMatchObject({
status: "reconnecting",
detail: "connection closed",
info: { endpoint: "nanobot.test", attempt: 1 },
})
sockets[1]?.emit("open")
sockets[1]?.emit("message", {
data: JSON.stringify({ event: "ready", chat_id: "", client_id: "client-2" }),
})
const outbound = sockets[1]?.sent.map((value) => JSON.parse(value)) || []
expect(outbound).toEqual([{ type: "attach", chat_id: "generated-chat" }])
expect(statuses.at(-1)?.status).toBe("connected")
client.close()
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })