fix(web): reset live metrics between connections

This commit is contained in:
Hypatia May
2026-07-28 20:28:01 +08:00
parent 47205cbb82
commit ef12895cf1
8 changed files with 178 additions and 5 deletions

View File

@@ -46,6 +46,8 @@ export interface ConnectionSinks {
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
onConnected?: () => void
/** After every failed generation closes and before retry starts. Not emitted when the controller is stopped. */
onDisconnected?: () => void
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
onStateChange?: (state: ConnectionState) => void
@@ -120,8 +122,8 @@ export class ConnectionController {
if (gen === this.generation && !ac.signal.aborted) ac.abort()
resolve()
}
void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle)
void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle)
void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, ac.signal, settle)
void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, ac.signal, settle)
})
try {
@@ -147,6 +149,7 @@ export class ConnectionController {
await failed
if (!this.isRunning()) return
this.callSink(this.sinks.onDisconnected)
this.emitState('reconnecting')
this.attempt += 1
console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)
@@ -165,10 +168,12 @@ export class ConnectionController {
private async pumpStream<F extends { type: string }>(
stream: AsyncIterable<RpcRequest<F>>,
sink: ((envelope: RpcRequest<F>) => void) | undefined,
signal: AbortSignal,
onEnd: () => void,
): Promise<void> {
try {
for await (const envelope of stream) {
if (signal.aborted) break
if (envelope.payload.type === 'stream/error') break
if (sink !== undefined) this.callSink(() => { sink(envelope) })
}

View File

@@ -7,7 +7,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '../src/client/api.ts'
import type { IApiClient, SessionId } from '../src/client/api.ts'
import type { ConnectionState } from '../src/client/connection.ts'
import { ConnectionController } from '../src/client/connection.ts'
import { FakeApiClient, deferred, ok } from './fake-api.ts'
@@ -104,6 +104,51 @@ describe('connection lifecycle', () => {
}
})
it('drops a sibling stream frame buffered behind a generation failure', async () => {
const api = new FakeApiClient()
const lateMux = deferred<undefined>()
const originalEvents = api.events
Object.defineProperty(api, 'events', {
value: {
host: (...args: Parameters<IApiClient['events']['host']>) => originalEvents.host(...args),
mux: (_payload: unknown, _signal: AbortSignal, onOpen?: () => void) => (async function* () {
onOpen?.()
await lateMux.promise
yield { rpcId: 'late-mux' as never, payload: subscribedFrame(2) }
})(),
} satisfies IApiClient['events'],
})
const muxSeen: number[] = []
let connected = 0
let disconnected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onMuxEnvelope: (envelope) => {
if (envelope.payload.type === 'session/subscribed') muxSeen.push(envelope.payload.lastSeq)
},
onConnected: () => { connected++ },
onDisconnected: () => {
disconnected++
lateMux.resolve(undefined)
controller.stop()
},
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(connected).toBe(1) })
api.pushHost({
type: 'stream/error',
error: { code: 'internal', message: 'host stream failed', details: {} },
})
await vi.waitFor(() => { expect(disconnected).toBe(1) })
await new Promise(resolve => setTimeout(resolve, 0))
expect(muxSeen).toEqual([])
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('isolates sink exceptions from the pump', async () => {
const api = new FakeApiClient()
const seen: string[] = []
@@ -181,7 +226,7 @@ describe('connection lifecycle', () => {
}
})
it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
it('reports every failed generation while deduplicating consecutive reconnecting state', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
let describeCalls = 0
@@ -190,10 +235,12 @@ describe('connection lifecycle', () => {
return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
}
const states: ConnectionState[] = []
let disconnected = 0
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onDisconnected: () => { disconnected++ },
onStateChange: state => states.push(state),
}, FAST)
controller.start()
@@ -201,6 +248,7 @@ describe('connection lifecycle', () => {
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(disconnected).toBe(2)
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
} finally {
controller.stop()